From 50952b631afaa83270fd3c68fa74016f374a0e2b Mon Sep 17 00:00:00 2001 From: Keysat Date: Thu, 7 May 2026 10:40:53 -0500 Subject: [PATCH] Initial public commit --- .gitignore | 15 ++ Cargo.toml | 41 +++++ LICENSE | 21 +++ README.md | 65 +++++++ examples/offline_verify.rs | 32 ++++ examples/online_validate.rs | 48 +++++ src/error.rs | 68 +++++++ src/key.rs | 305 +++++++++++++++++++++++++++++++ src/lib.rs | 51 ++++++ src/online.rs | 353 ++++++++++++++++++++++++++++++++++++ src/pubkey.rs | 36 ++++ src/verify.rs | 122 +++++++++++++ 12 files changed, 1157 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 examples/offline_verify.rs create mode 100644 examples/online_validate.rs create mode 100644 src/error.rs create mode 100644 src/key.rs create mode 100644 src/lib.rs create mode 100644 src/online.rs create mode 100644 src/pubkey.rs create mode 100644 src/verify.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d1def3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Cargo build artifacts +target/ +Cargo.lock.bak + +# Editor / OS cruft +.DS_Store +.idea/ +.vscode/ +*.swp +*.bak +*.tmp + +# Env / secrets +.env +.env.local diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..770edd9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "licensing-client" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +description = "Client library for Keysat. Verifies signed license keys offline and wraps the HTTP API for purchase and revocation checks." +license = "MIT OR Apache-2.0" +repository = "https://github.com/keysat-xyz/keysat" +keywords = ["bitcoin", "licensing", "btcpay", "start9"] +categories = ["authentication", "cryptography"] + +[features] +# Default is offline-only verification — no network, no async runtime required. +default = ["offline"] +offline = [] +# Online verification + purchase via reqwest. Async (tokio). +online = ["reqwest", "tokio", "url"] + +[dependencies] +ed25519-dalek = { version = "2", features = ["pkcs8", "pem"] } +sha2 = "0.10" +data-encoding = "2" +thiserror = "1" + +# Async / HTTP: only compiled with the `online` feature. +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true } +tokio = { version = "1", features = ["rt", "macros"], optional = true } +url = { version = "2", optional = true } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[dev-dependencies] +tokio = { version = "1", features = ["full"] } + +[[example]] +name = "offline_verify" +required-features = ["offline"] + +[[example]] +name = "online_validate" +required-features = ["online"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1b63053 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Keysat + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad0be9e --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# licensing-client + +Rust client for [`Keysat`](https://github.com/keysat-xyz/keysat) — a self-hosted Bitcoin-paid software licensing server that runs on Start9. + +## What you get + +- **Offline verification**: check a license key with just the issuing server's public key. No network. Default feature. +- **Online validation**: live revocation check and fingerprint binding via the service's `/v1/validate` endpoint. Optional. +- **Purchase flow**: kick off a BTCPay checkout and poll for the issued key. Optional. + +## Install + +```toml +[dependencies] +licensing-client = "0.1" + +# Or, with the online features: +licensing-client = { version = "0.1", features = ["online"] } +``` + +## 5-line offline check + +```rust +use licensing_client::{Verifier, PublicKeyPem}; + +let pubkey = PublicKeyPem::from_str(include_str!("issuer.pub"))?; +let verifier = Verifier::new(pubkey); +let ok = verifier.verify(&key_from_user)?; +println!("licensed for product {}", ok.product_id); +``` + +That's the whole integration. `include_str!("issuer.pub")` embeds your public key at build time; if the verifier says OK, the key is real and was issued by you. + +## 10-line online check (with revocation + fingerprint) + +```rust +use licensing_client::online::Client; + +let client = Client::new("https://license.example.com")?; +let result = client + .validate(&key_from_user, Some("my-product"), Some(&machine_fingerprint)) + .await?; +if !result.ok { + eprintln!("rejected: {:?}", result.reason); + std::process::exit(1); +} +``` + +The server enforces revocation live and does trust-on-first-use fingerprint binding, so the same key used from a second machine gets rejected. + +## Purchase flow + +```rust +let session = client.start_purchase("my-product", None, None).await?; +// open session.checkout_url in the user's browser +loop { + let poll = client.poll_purchase(&session.invoice_id).await?; + if let Some(key) = poll.license_key { break key; } + tokio::time::sleep(std::time::Duration::from_secs(5)).await; +} +``` + +## License + +MIT OR Apache-2.0. diff --git a/examples/offline_verify.rs b/examples/offline_verify.rs new file mode 100644 index 0000000..fb6d142 --- /dev/null +++ b/examples/offline_verify.rs @@ -0,0 +1,32 @@ +//! Minimum-viable license check, entirely offline. +//! +//! Run with: +//! +//! cargo run --example offline_verify --no-default-features --features offline -- +//! +//! In real life you'd embed the public key with `include_str!` — here we +//! read it from an env var so the example has zero build-time coupling. + +use licensing_client::{PublicKeyPem, Verifier}; +use std::env; + +fn main() { + let pem = env::var("LICENSING_PUBKEY_PEM").expect("set LICENSING_PUBKEY_PEM to your issuer's public key"); + let key = env::args() + .nth(1) + .expect("pass a license key as the first argument"); + + let verifier = Verifier::new(PublicKeyPem::from_str(&pem).expect("parse pubkey")); + match verifier.verify(&key) { + Ok(ok) => { + println!("license OK"); + println!(" license_id = {}", ok.license_id); + println!(" product_id = {}", ok.product_id); + println!(" issued_at = {}", ok.payload.issued_at); + } + Err(e) => { + eprintln!("license REJECTED: {e}"); + std::process::exit(1); + } + } +} diff --git a/examples/online_validate.rs b/examples/online_validate.rs new file mode 100644 index 0000000..95be2c0 --- /dev/null +++ b/examples/online_validate.rs @@ -0,0 +1,48 @@ +//! Full purchase-and-validate round trip against a running +//! licensing-service instance. +//! +//! cargo run --example online_validate --features online -- +//! +//! The example will start a purchase, print the BTCPay checkout URL for +//! you to pay, then poll until a license key is issued and validate it. + +use licensing_client::online::Client; +use std::time::Duration; +use tokio::time::sleep; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let mut args = std::env::args().skip(1); + let base_url = args.next().expect("pass base URL, e.g. https://license.example.com"); + let product_slug = args.next().expect("pass product slug"); + + let client = Client::new(&base_url)?; + + let session = client + .start_purchase(&product_slug, None, None) + .await?; + println!("open the checkout in your browser:"); + println!(" {}", session.checkout_url); + println!("waiting for settlement..."); + + let license = loop { + sleep(Duration::from_secs(5)).await; + let p = client.poll_purchase(&session.invoice_id).await?; + if let Some(k) = p.license_key { + break k; + } + println!(" status: {}", p.status); + if p.status == "expired" || p.status == "invalid" { + anyhow::bail!("invoice ended in status {}", p.status); + } + }; + + println!("license issued:\n {license}"); + + let validated = client + .validate(&license, Some(&product_slug), None) + .await?; + println!("server says: ok={} reason={:?}", validated.ok, validated.reason); + + Ok(()) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..7dcaff9 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,68 @@ +//! Error types. + +use thiserror::Error; + +/// A `Result` alias for this crate. +pub type Result = std::result::Result; + +/// Errors returned by the licensing client. +#[derive(Debug, Error)] +pub enum Error { + /// The key string didn't match the expected `LIC1--` shape. + #[error("bad key format: {0}")] + BadFormat(&'static str), + + /// Base32 decoding of the payload or signature failed. + #[error("bad encoding: {0}")] + BadEncoding(&'static str), + + /// The payload didn't contain the expected number of bytes. + #[error("bad payload length: expected {expected}, got {got}")] + BadPayloadLength { + /// expected length + expected: usize, + /// observed length + got: usize, + }, + + /// Unknown key format version. + #[error("unsupported key version {0}")] + UnsupportedVersion(u8), + + /// The signature failed to verify against the public key. + #[error("signature verification failed")] + BadSignature, + + /// The key's `expires_at` is in the past. Only returned by + /// [`crate::Verifier::verify_with_time`]. + #[error("license expired")] + Expired, + + /// Reading or parsing the supplied public-key PEM blob failed. + #[error("bad public key PEM: {0}")] + BadPublicKey(String), + + /// An HTTP call returned an error. Only present with the `online` feature. + #[cfg(feature = "online")] + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + + /// The server returned an error response. Includes status + body. + #[cfg(feature = "online")] + #[error("server error ({status}): {body}")] + Server { + /// HTTP status + status: u16, + /// response body as text + body: String, + }, + + /// A URL couldn't be parsed. Online only. + #[cfg(feature = "online")] + #[error("bad URL: {0}")] + BadUrl(String), + + /// Generic catch-all. + #[error("{0}")] + Other(String), +} diff --git a/src/key.rs b/src/key.rs new file mode 100644 index 0000000..5d543bc --- /dev/null +++ b/src/key.rs @@ -0,0 +1,305 @@ +//! License-key parsing. Matches the wire format defined by the service's +//! `crypto` module exactly — do not edit one without the other. +//! +//! ## Wire format +//! +//! A key string looks like `LIC1--`. Both halves +//! are Crockford-style base32 (no padding) of the raw bytes. +//! +//! **v1 payload (74 bytes, fixed):** +//! ```text +//! offset size field +//! 0 1 version = 1 +//! 1 1 flags +//! 2 16 product_id (UUID bytes, big-endian) +//! 18 16 license_id (UUID bytes, big-endian) +//! 34 8 issued_at (i64 unix seconds, big-endian) +//! 42 32 fingerprint_hash (SHA-256 of the machine fingerprint, +//! or all-zeros) +//! ``` +//! +//! **v2 payload (83 bytes + variable-length entitlements):** +//! ```text +//! offset size field +//! 0 1 version = 2 +//! 1 1 flags +//! 2 16 product_id +//! 18 16 license_id +//! 34 8 issued_at +//! 42 8 expires_at (i64 unix seconds, 0 = perpetual) +//! 50 32 fingerprint_hash +//! 82 1 num_entitlements (u8, 0..=255) +//! 83 * entitlements, each length-prefixed: +//! [u8 len] [len bytes of UTF-8 slug] +//! ``` +//! +//! Clients verifying a v1 key zero-fill the v2-only fields so application +//! code can treat both versions uniformly. + +use crate::error::{Error, Result}; +use data_encoding::BASE32_NOPAD; + +/// Key prefix every valid key starts with. +pub const KEY_PREFIX: &str = "LIC1"; + +/// Ed25519 signature is always 64 bytes on the wire. +pub(crate) const SIGNATURE_LEN: usize = 64; + +/// v1 payload length (fixed). +pub(crate) const PAYLOAD_V1_LEN: usize = 74; +/// v2 fixed-head length — bytes before the variable entitlements tail. +pub(crate) const PAYLOAD_V2_HEAD_LEN: usize = 83; + +/// v1 format identifier. +pub const KEY_VERSION_V1: u8 = 1; +/// v2 format identifier. New keys are issued as v2. +pub const KEY_VERSION_V2: u8 = 2; + +/// Highest format version this client understands. +pub const KEY_VERSION: u8 = KEY_VERSION_V2; + +/// Set when the key is bound to a specific machine fingerprint hash. +pub const FLAG_FINGERPRINT_BOUND: u8 = 0b0000_0001; +/// Set on keys that represent a trial — the application can treat this as a +/// hint to show a "trial" badge, limit features, nag before expiry, etc. +pub const FLAG_TRIAL: u8 = 0b0000_0010; + +/// Decoded payload fields. 16-byte UUIDs are kept as raw bytes here — the +/// server also stores them as UUIDs but this library doesn't require the +/// `uuid` crate just to render hex. +#[derive(Debug, Clone)] +pub struct LicensePayload { + /// Format version (1 or 2). + pub version: u8, + /// Feature flags (see [`FLAG_FINGERPRINT_BOUND`], [`FLAG_TRIAL`]). + pub flags: u8, + /// 16-byte product id. + pub product_id: [u8; 16], + /// 16-byte license id. + pub license_id: [u8; 16], + /// Unix seconds issued. + pub issued_at: i64, + /// Unix seconds when the key expires, or `0` for perpetual. Always `0` + /// on v1 keys. + pub expires_at: i64, + /// SHA-256 hash of the bound fingerprint, or all-zeros. + pub fingerprint_hash: [u8; 32], + /// Entitlement slugs granted by this license. Empty on v1 keys. + pub entitlements: Vec, +} + +impl LicensePayload { + /// True if this key was issued bound to a specific machine fingerprint. + pub fn is_fingerprint_bound(&self) -> bool { + self.flags & FLAG_FINGERPRINT_BOUND != 0 + } + + /// True if this key represents a trial. + pub fn is_trial(&self) -> bool { + self.flags & FLAG_TRIAL != 0 + } + + /// True if `now` (unix seconds) is at or past `expires_at`. Always false + /// for perpetual keys (`expires_at == 0`). + pub fn is_expired_at(&self, now_unix: i64) -> bool { + self.expires_at != 0 && now_unix >= self.expires_at + } + + /// True if this license grants the given entitlement slug. + pub fn has_entitlement(&self, slug: &str) -> bool { + self.entitlements.iter().any(|e| e == slug) + } + + /// Render the 16-byte product id as a standard lowercase UUID string. + pub fn product_uuid(&self) -> String { + uuid_to_string(&self.product_id) + } + + /// Render the 16-byte license id as a lowercase UUID string. + pub fn license_uuid(&self) -> String { + uuid_to_string(&self.license_id) + } + + pub(crate) fn from_bytes(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err(Error::BadPayloadLength { + expected: PAYLOAD_V1_LEN, + got: 0, + }); + } + match bytes[0] { + KEY_VERSION_V1 => Self::from_bytes_v1(bytes), + KEY_VERSION_V2 => Self::from_bytes_v2(bytes), + other => Err(Error::UnsupportedVersion(other)), + } + } + + fn from_bytes_v1(bytes: &[u8]) -> Result { + if bytes.len() != PAYLOAD_V1_LEN { + return Err(Error::BadPayloadLength { + expected: PAYLOAD_V1_LEN, + got: bytes.len(), + }); + } + let mut product_id = [0u8; 16]; + product_id.copy_from_slice(&bytes[2..18]); + let mut license_id = [0u8; 16]; + license_id.copy_from_slice(&bytes[18..34]); + let issued_at = i64::from_be_bytes(bytes[34..42].try_into().unwrap()); + let mut fingerprint_hash = [0u8; 32]; + fingerprint_hash.copy_from_slice(&bytes[42..74]); + Ok(LicensePayload { + version: KEY_VERSION_V1, + flags: bytes[1], + product_id, + license_id, + issued_at, + expires_at: 0, + fingerprint_hash, + entitlements: Vec::new(), + }) + } + + fn from_bytes_v2(bytes: &[u8]) -> Result { + if bytes.len() < PAYLOAD_V2_HEAD_LEN { + return Err(Error::BadPayloadLength { + expected: PAYLOAD_V2_HEAD_LEN, + got: bytes.len(), + }); + } + let mut product_id = [0u8; 16]; + product_id.copy_from_slice(&bytes[2..18]); + let mut license_id = [0u8; 16]; + license_id.copy_from_slice(&bytes[18..34]); + let issued_at = i64::from_be_bytes(bytes[34..42].try_into().unwrap()); + let expires_at = i64::from_be_bytes(bytes[42..50].try_into().unwrap()); + let mut fingerprint_hash = [0u8; 32]; + fingerprint_hash.copy_from_slice(&bytes[50..82]); + let num_ents = bytes[82] as usize; + + let mut entitlements = Vec::with_capacity(num_ents); + let mut cursor = PAYLOAD_V2_HEAD_LEN; + for _ in 0..num_ents { + if cursor >= bytes.len() { + return Err(Error::BadFormat("truncated entitlement list")); + } + let len = bytes[cursor] as usize; + cursor += 1; + if cursor + len > bytes.len() { + return Err(Error::BadFormat("truncated entitlement")); + } + let slug = std::str::from_utf8(&bytes[cursor..cursor + len]) + .map_err(|_| Error::BadFormat("entitlement not utf-8"))? + .to_string(); + entitlements.push(slug); + cursor += len; + } + // Payload must consume exactly all bytes. + if cursor != bytes.len() { + return Err(Error::BadFormat("trailing bytes in payload")); + } + + Ok(LicensePayload { + version: KEY_VERSION_V2, + flags: bytes[1], + product_id, + license_id, + issued_at, + expires_at, + fingerprint_hash, + entitlements, + }) + } +} + +/// A parsed (but not yet verified) license key. +#[derive(Debug, Clone)] +pub struct LicenseKey { + /// The parsed payload. + pub payload: LicensePayload, + /// The raw signed-over message (for re-verifying signatures). Length is + /// 74 for v1 keys and `>= 83` for v2 keys. + pub signed_bytes: Vec, + /// Ed25519 signature over `signed_bytes`. + pub signature: [u8; SIGNATURE_LEN], +} + +impl LicenseKey { + /// Parse a key string like `LIC1-XXXX...-YYYY...` into its components. + /// Does NOT verify the signature — use [`crate::Verifier`] for that. + pub fn parse(key: &str) -> Result { + let key = key.trim(); + let mut parts = key.splitn(3, '-'); + let prefix = parts.next().ok_or(Error::BadFormat("missing prefix"))?; + if prefix != KEY_PREFIX { + return Err(Error::BadFormat("unknown prefix")); + } + let payload_b32 = parts.next().ok_or(Error::BadFormat("missing payload"))?; + let signature_b32 = parts.next().ok_or(Error::BadFormat("missing signature"))?; + + let payload_bytes = BASE32_NOPAD + .decode(payload_b32.as_bytes()) + .map_err(|_| Error::BadEncoding("payload"))?; + let signature_bytes = BASE32_NOPAD + .decode(signature_b32.as_bytes()) + .map_err(|_| Error::BadEncoding("signature"))?; + + let payload = LicensePayload::from_bytes(&payload_bytes)?; + + if signature_bytes.len() != SIGNATURE_LEN { + return Err(Error::BadFormat("signature wrong size")); + } + let mut sig = [0u8; SIGNATURE_LEN]; + sig.copy_from_slice(&signature_bytes); + + Ok(LicenseKey { + payload, + signed_bytes: payload_bytes, + signature: sig, + }) + } +} + +fn uuid_to_string(b: &[u8; 16]) -> String { + format!( + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], + b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15] + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_wrong_prefix() { + let err = LicenseKey::parse("WRONG-AAAA-BBBB").unwrap_err(); + assert!(matches!(err, Error::BadFormat(_))); + } + + #[test] + fn rejects_missing_parts() { + assert!(LicenseKey::parse("LIC1").is_err()); + assert!(LicenseKey::parse("LIC1-AAAA").is_err()); + } + + #[test] + fn rejects_unknown_version() { + // v99 payload: version=99, 82 zero bytes of filler, no entitlements. + let mut v99 = vec![99u8]; + v99.extend(vec![0u8; PAYLOAD_V2_HEAD_LEN]); // enough bytes for v2 if version were right + let err = LicensePayload::from_bytes(&v99).unwrap_err(); + assert!(matches!(err, Error::UnsupportedVersion(99))); + } + + #[test] + fn parses_v1_fixed_size() { + let mut p = vec![KEY_VERSION_V1, 0u8]; + p.extend(std::iter::repeat(0u8).take(PAYLOAD_V1_LEN - 2)); + let parsed = LicensePayload::from_bytes(&p).unwrap(); + assert_eq!(parsed.version, 1); + assert_eq!(parsed.expires_at, 0); + assert!(parsed.entitlements.is_empty()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..2f90cdc --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,51 @@ +//! # licensing-client +//! +//! Client library for the **licensing-service** — an open-source Bitcoin-paid +//! software licensing server for Start9 boxes. +//! +//! This crate gives your app everything it needs to check license keys +//! issued by a `licensing-service` instance: +//! +//! - **Offline verification** — validate a signed license key without any +//! network call. You only need the issuing server's Ed25519 public key +//! (typically embedded in your binary at build time). +//! - **Online validation** — POST to the service's `/v1/validate` endpoint +//! for live revocation checking and TOFU fingerprint binding. +//! - **Purchase flow** — open a checkout URL for the buyer and poll for a +//! newly-issued license key. +//! +//! ## 5-line integration example +//! +//! ```no_run +//! use licensing_client::{Verifier, PublicKeyPem}; +//! +//! let pubkey = PublicKeyPem::from_str(include_str!("../my_issuer.pub")).unwrap(); +//! let verifier = Verifier::new(pubkey); +//! let result = verifier.verify("LIC1-...").expect("valid license"); +//! println!("license ok for product {}", result.product_id); +//! ``` +//! +//! The `online` feature (off by default) adds an async HTTP client for +//! revocation checks and the purchase flow. + +#![deny(missing_docs)] + +pub mod error; +pub mod key; +#[cfg(feature = "online")] +pub mod online; +pub mod pubkey; +pub mod verify; + +pub use error::{Error, Result}; +pub use key::{ + LicenseKey, LicensePayload, FLAG_FINGERPRINT_BOUND, FLAG_TRIAL, KEY_VERSION, + KEY_VERSION_V1, KEY_VERSION_V2, +}; +pub use pubkey::PublicKeyPem; +pub use verify::{Verifier, VerifyOk}; + +#[cfg(feature = "online")] +pub use online::{ + Client, MachineResponse, PollResponse, PurchaseSession, ValidateRequest, ValidateResponse, +}; diff --git a/src/online.rs b/src/online.rs new file mode 100644 index 0000000..c7bed09 --- /dev/null +++ b/src/online.rs @@ -0,0 +1,353 @@ +//! Online operations against a running `licensing-service` instance. +//! +//! Two things this module gives you: +//! +//! 1. [`Client::validate`] — server-authoritative validation that checks +//! revocation and (optionally) binds / enforces the machine fingerprint. +//! 2. [`Client::start_purchase`] + [`Client::poll_purchase`] — kick off a +//! BTCPay checkout and watch for the resulting license key. +//! +//! All methods are `async` and built on `reqwest`. Enable with the `online` +//! feature. + +use crate::error::{Error, Result}; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use url::Url; + +/// An async HTTP client pinned to one licensing-service base URL. +#[derive(Debug, Clone)] +pub struct Client { + http: reqwest::Client, + base: Url, +} + +impl Client { + /// Construct a client pointed at `base_url` (e.g. `"https://license.example.com"`). + pub fn new(base_url: &str) -> Result { + let base = Url::parse(base_url).map_err(|e| Error::BadUrl(e.to_string()))?; + Ok(Self { + http: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(Error::Http)?, + base, + }) + } + + /// Fetch the server's public key as a PEM string. Useful for bootstrap + /// scripts; in production you should embed the key into your binary + /// instead, so a compromised server can't swap its own key under you. + pub async fn fetch_pubkey(&self) -> Result { + let url = self + .base + .join("/v1/pubkey") + .map_err(|e| Error::BadUrl(e.to_string()))?; + let resp = self.http.get(url).send().await?; + let status = resp.status(); + let text = resp.text().await?; + if !status.is_success() { + return Err(Error::Server { + status: status.as_u16(), + body: text, + }); + } + #[derive(Deserialize)] + struct PubkeyResp { + public_key_pem: String, + } + let p: PubkeyResp = + serde_json::from_str(&text).map_err(|e| Error::Other(e.to_string()))?; + Ok(p.public_key_pem) + } + + /// Call the server's `/v1/validate` endpoint. Server enforces + /// revocation, expiry/grace, suspension, entitlements, and (if a + /// fingerprint is supplied) seat binding / cap enforcement. + pub async fn validate( + &self, + key: &str, + product_slug: Option<&str>, + fingerprint: Option<&str>, + ) -> Result { + self.validate_full(ValidateRequest { + key, + product_slug, + fingerprint, + hostname: None, + platform: None, + }) + .await + } + + /// Same as [`Self::validate`] but with optional `hostname` and `platform` + /// descriptors recorded against the machine row on activation. + pub async fn validate_full(&self, req: ValidateRequest<'_>) -> Result { + let url = self + .base + .join("/v1/validate") + .map_err(|e| Error::BadUrl(e.to_string()))?; + let resp = self.http.post(url).json(&req).send().await?; + let status = resp.status(); + let text = resp.text().await?; + if status != StatusCode::OK { + return Err(Error::Server { + status: status.as_u16(), + body: text, + }); + } + serde_json::from_str(&text).map_err(|e| Error::Other(e.to_string())) + } + + /// Send a heartbeat for an already-activated seat. Lightweight — the + /// server updates `last_heartbeat_at` so admin tooling can spot stale + /// installs. + pub async fn heartbeat(&self, key: &str, fingerprint: &str) -> Result { + self.machine_call("/v1/machines/heartbeat", key, fingerprint, None).await + } + + /// Explicitly activate a seat for `fingerprint`. Behaves identically to a + /// `/v1/validate` call that would have auto-activated — useful when you + /// want to prompt the user about seat usage before starting up. + pub async fn activate( + &self, + key: &str, + fingerprint: &str, + ) -> Result { + self.machine_call("/v1/machines/activate", key, fingerprint, None).await + } + + /// Free the seat currently held by `fingerprint`. Returns `ok: true` on + /// success; the user can then activate on a different machine without + /// hitting the seat cap. + pub async fn deactivate( + &self, + key: &str, + fingerprint: &str, + reason: Option<&str>, + ) -> Result { + self.machine_call("/v1/machines/deactivate", key, fingerprint, reason).await + } + + async fn machine_call( + &self, + path: &str, + key: &str, + fingerprint: &str, + reason: Option<&str>, + ) -> Result { + let url = self + .base + .join(path) + .map_err(|e| Error::BadUrl(e.to_string()))?; + #[derive(Serialize)] + struct Req<'a> { + key: &'a str, + fingerprint: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option<&'a str>, + } + let body = Req { key, fingerprint, reason }; + let resp = self.http.post(url).json(&body).send().await?; + let status = resp.status(); + let text = resp.text().await?; + if status != StatusCode::OK { + return Err(Error::Server { + status: status.as_u16(), + body: text, + }); + } + serde_json::from_str(&text).map_err(|e| Error::Other(e.to_string())) + } + + /// Start a purchase for `product_slug`. Returns the BTCPay checkout URL + /// to open in the buyer's browser and the invoice id to poll. + pub async fn start_purchase( + &self, + product_slug: &str, + buyer_email: Option<&str>, + redirect_url: Option<&str>, + ) -> Result { + let url = self + .base + .join("/v1/purchase") + .map_err(|e| Error::BadUrl(e.to_string()))?; + + #[derive(Serialize)] + struct Req<'a> { + product: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + buyer_email: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + redirect_url: Option<&'a str>, + } + let body = Req { + product: product_slug, + buyer_email, + redirect_url, + }; + let resp = self.http.post(url).json(&body).send().await?; + let status = resp.status(); + let text = resp.text().await?; + if !status.is_success() { + return Err(Error::Server { + status: status.as_u16(), + body: text, + }); + } + serde_json::from_str(&text).map_err(|e| Error::Other(e.to_string())) + } + + /// Poll a purchase by its invoice id. Returns the current status and, + /// once the invoice has settled, the signed `license_key` string. + pub async fn poll_purchase(&self, invoice_id: &str) -> Result { + let url = self + .base + .join(&format!("/v1/purchase/{invoice_id}")) + .map_err(|e| Error::BadUrl(e.to_string()))?; + let resp = self.http.get(url).send().await?; + let status = resp.status(); + let text = resp.text().await?; + if !status.is_success() { + return Err(Error::Server { + status: status.as_u16(), + body: text, + }); + } + serde_json::from_str(&text).map_err(|e| Error::Other(e.to_string())) + } +} + +// --- request / response types --- + +/// Full request body for [`Client::validate_full`]. +#[derive(Debug, Clone, Serialize)] +pub struct ValidateRequest<'a> { + /// The full `LIC1-...` key string. + pub key: &'a str, + /// Optional product slug the caller expects the key to cover. + #[serde(skip_serializing_if = "Option::is_none")] + pub product_slug: Option<&'a str>, + /// Optional raw fingerprint. Sending it enables seat binding / cap + /// enforcement on the server side. + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option<&'a str>, + /// Optional client-supplied hostname, recorded against the machine row. + #[serde(skip_serializing_if = "Option::is_none")] + pub hostname: Option<&'a str>, + /// Optional client-supplied platform descriptor (e.g. `"linux-x86_64"`). + #[serde(skip_serializing_if = "Option::is_none")] + pub platform: Option<&'a str>, +} + +/// Response from `/v1/validate`. `ok` tells you the bottom line; `reason` +/// is populated on failure (values like `"revoked"`, `"fingerprint_mismatch"`, +/// `"bad_signature"`, `"not_found"`, `"bad_format"`, `"product_mismatch"`, +/// `"expired"`, `"suspended"`, `"too_many_machines"`, `"rate_limited"`). +#[derive(Debug, Clone, Deserialize, Default)] +pub struct ValidateResponse { + /// Whether the key is currently valid. + pub ok: bool, + /// Machine-readable reason on failure. + #[serde(default)] + pub reason: Option, + /// License id on success. + #[serde(default)] + pub license_id: Option, + /// Product id on success. + #[serde(default)] + pub product_id: Option, + /// Product slug on success. + #[serde(default)] + pub product_slug: Option, + /// Issue timestamp (RFC 3339) on success. + #[serde(default)] + pub issued_at: Option, + /// Expiry timestamp (RFC 3339) if the license has one. + #[serde(default)] + pub expires_at: Option, + /// End of the grace window (RFC 3339) if the license is currently in + /// its grace period. + #[serde(default)] + pub grace_until: Option, + /// True when the license is past `expires_at` but still inside the + /// operator's configured grace window. The server returns `ok: true` + /// in this case and populates `grace_until`. + #[serde(default)] + pub in_grace_period: Option, + /// True if this license is flagged as a trial. + #[serde(default)] + pub is_trial: Option, + /// Entitlement slugs granted by the license. + #[serde(default)] + pub entitlements: Vec, + /// License status string (`active`, `suspended`, `revoked`). + #[serde(default)] + pub status: Option, + /// The id of the machine row this call activated or matched (when a + /// fingerprint was supplied). + #[serde(default)] + pub machine_id: Option, + /// The license's seat cap. `0` = unlimited, `1` = single-seat, `n` = + /// n-seat. + #[serde(default)] + pub max_machines: Option, +} + +impl ValidateResponse { + /// True if the license grants the given entitlement slug. + pub fn has_entitlement(&self, slug: &str) -> bool { + self.entitlements.iter().any(|e| e == slug) + } +} + +/// Response from the `/v1/machines/*` endpoints. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct MachineResponse { + /// Whether the call succeeded. + pub ok: bool, + /// Machine-readable reason on failure. + #[serde(default)] + pub reason: Option, + /// The machine id, when present. + #[serde(default)] + pub machine_id: Option, + /// Current count of active machines for this license. + #[serde(default)] + pub active_count: Option, + /// Seat cap for this license (echo of `max_machines`). + #[serde(default)] + pub max_machines: Option, +} + +/// Response from `/v1/purchase` when starting a purchase. +#[derive(Debug, Clone, Deserialize)] +pub struct PurchaseSession { + /// Our internal invoice id — use with [`Client::poll_purchase`]. + pub invoice_id: String, + /// BTCPay's invoice id (opaque). + pub btcpay_invoice_id: String, + /// URL to open in the buyer's browser. + pub checkout_url: String, + /// Amount in satoshis. + pub amount_sats: i64, + /// Where the service recommends polling. + pub poll_url: String, +} + +/// Response from polling `/v1/purchase/:invoice_id`. +#[derive(Debug, Clone, Deserialize)] +pub struct PollResponse { + /// Our invoice id. + pub invoice_id: String, + /// `pending | settled | expired | invalid`. + pub status: String, + /// Product id (UUID string). + pub product_id: String, + /// Price in satoshis. + pub amount_sats: i64, + /// Populated only once the license has been issued. + pub license_key: Option, + /// Populated with the license row's id once issued. + pub license_id: Option, +} diff --git a/src/pubkey.rs b/src/pubkey.rs new file mode 100644 index 0000000..612ec9e --- /dev/null +++ b/src/pubkey.rs @@ -0,0 +1,36 @@ +//! Wrapper around the issuing server's Ed25519 public key. +//! +//! Typical usage: embed the PEM bytes of your issuer's public key into your +//! binary at build time using `include_str!`, then construct a +//! [`PublicKeyPem`] at startup. + +use crate::error::{Error, Result}; +use ed25519_dalek::pkcs8::DecodePublicKey; +use ed25519_dalek::VerifyingKey; + +/// Parsed Ed25519 public key, ready for signature verification. +#[derive(Debug, Clone)] +pub struct PublicKeyPem { + pub(crate) verifying: VerifyingKey, +} + +impl PublicKeyPem { + /// Parse a PEM-encoded Ed25519 public key. Matches the format emitted + /// by the licensing-service `/v1/pubkey` endpoint. + #[allow(clippy::should_implement_trait)] + pub fn from_str(pem: &str) -> Result { + let verifying = VerifyingKey::from_public_key_pem(pem) + .map_err(|e| Error::BadPublicKey(e.to_string()))?; + Ok(Self { verifying }) + } + + /// Parse raw 32-byte public-key material (no PEM envelope). + pub fn from_bytes(bytes: &[u8]) -> Result { + let arr: &[u8; 32] = bytes.try_into().map_err(|_| { + Error::BadPublicKey("raw Ed25519 public key must be exactly 32 bytes".into()) + })?; + let verifying = + VerifyingKey::from_bytes(arr).map_err(|e| Error::BadPublicKey(e.to_string()))?; + Ok(Self { verifying }) + } +} diff --git a/src/verify.rs b/src/verify.rs new file mode 100644 index 0000000..a1b7268 --- /dev/null +++ b/src/verify.rs @@ -0,0 +1,122 @@ +//! Offline signature verification. +//! +//! This is all you need if your app is happy to trust a key forever once it +//! was issued by the right server. For live revocation checking, combine +//! with the [`crate::online`] module. + +use crate::error::{Error, Result}; +use crate::key::{LicenseKey, LicensePayload}; +use crate::pubkey::PublicKeyPem; +use ed25519_dalek::{Signature, Verifier as _}; +use sha2::{Digest, Sha256}; + +/// Verifies license keys against a single issuing server's public key. +/// +/// Cheap to construct and cheap to call; shareable across threads +/// (`Clone` + `Send` + `Sync`). +#[derive(Debug, Clone)] +pub struct Verifier { + pubkey: PublicKeyPem, +} + +/// Successful verification result. +#[derive(Debug, Clone)] +pub struct VerifyOk { + /// Parsed payload fields. + pub payload: LicensePayload, + /// License id as a lowercase UUID string. + pub license_id: String, + /// Product id as a lowercase UUID string. + pub product_id: String, +} + +impl VerifyOk { + /// Convenience: true if the key's `expires_at` is at or before `now`. + /// Always false for perpetual keys. + pub fn is_expired_at(&self, now_unix: i64) -> bool { + self.payload.is_expired_at(now_unix) + } + + /// Convenience: true if the key grants the given entitlement slug. + pub fn has_entitlement(&self, slug: &str) -> bool { + self.payload.has_entitlement(slug) + } + + /// Convenience: true if the key is flagged as a trial. + pub fn is_trial(&self) -> bool { + self.payload.is_trial() + } +} + +impl Verifier { + /// Construct a verifier bound to a single issuing server's public key. + pub fn new(pubkey: PublicKeyPem) -> Self { + Self { pubkey } + } + + /// Verify a license key string. Returns either a [`VerifyOk`] on + /// success or an [`Error`] explaining the failure. + /// + /// This checks only the cryptographic signature and format. It does + /// **not** check expiry — call [`VerifyOk::is_expired_at`] if you want + /// that, or use [`Self::verify_with_time`] to get an error on expired + /// keys directly. + pub fn verify(&self, key: &str) -> Result { + let parsed = LicenseKey::parse(key)?; + self.verify_parsed(&parsed) + } + + /// Verify an already-parsed key. + pub fn verify_parsed(&self, key: &LicenseKey) -> Result { + let sig = Signature::from_bytes(&key.signature); + self.pubkey + .verifying + .verify(&key.signed_bytes, &sig) + .map_err(|_| Error::BadSignature)?; + + Ok(VerifyOk { + license_id: key.payload.license_uuid(), + product_id: key.payload.product_uuid(), + payload: key.payload.clone(), + }) + } + + /// Verify a key AND enforce that, if the key is fingerprint-bound, the + /// provided fingerprint matches. If the key is *not* fingerprint-bound, + /// the fingerprint is ignored (call [`Self::verify`] instead if that's + /// what you want). + pub fn verify_with_fingerprint(&self, key: &str, fingerprint: &str) -> Result { + let parsed = LicenseKey::parse(key)?; + let ok = self.verify_parsed(&parsed)?; + if ok.payload.is_fingerprint_bound() { + let expected = hash_fingerprint(fingerprint); + if expected != ok.payload.fingerprint_hash { + return Err(Error::BadSignature); + } + } + Ok(ok) + } + + /// Verify a key and additionally reject it if `now_unix` is past its + /// `expires_at` (with no grace — for grace-window logic, use an online + /// check against `/v1/validate`). Perpetual keys (`expires_at = 0`) are + /// accepted regardless of `now_unix`. + pub fn verify_with_time(&self, key: &str, now_unix: i64) -> Result { + let ok = self.verify(key)?; + if ok.payload.is_expired_at(now_unix) { + return Err(Error::Expired); + } + Ok(ok) + } +} + +/// Hash a raw fingerprint string to the 32-byte form embedded in keys. +/// Matches the service-side implementation. +pub fn hash_fingerprint(raw: &str) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(raw.as_bytes()); + let digest = hasher.finalize(); + let mut out = [0u8; 32]; + out.copy_from_slice(&digest); + out +}