Initial public commit

This commit is contained in:
Keysat
2026-05-07 10:40:53 -05:00
commit 50952b631a
12 changed files with 1157 additions and 0 deletions
+15
View File
@@ -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
+41
View File
@@ -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"]
+21
View File
@@ -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.
+65
View File
@@ -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.
+32
View File
@@ -0,0 +1,32 @@
//! Minimum-viable license check, entirely offline.
//!
//! Run with:
//!
//! cargo run --example offline_verify --no-default-features --features offline -- <LIC1-...>
//!
//! 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);
}
}
}
+48
View File
@@ -0,0 +1,48 @@
//! Full purchase-and-validate round trip against a running
//! licensing-service instance.
//!
//! cargo run --example online_validate --features online -- <base-url> <product-slug>
//!
//! 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(())
}
+68
View File
@@ -0,0 +1,68 @@
//! Error types.
use thiserror::Error;
/// A `Result` alias for this crate.
pub type Result<T> = std::result::Result<T, Error>;
/// Errors returned by the licensing client.
#[derive(Debug, Error)]
pub enum Error {
/// The key string didn't match the expected `LIC1-<payload>-<sig>` 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),
}
+305
View File
@@ -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-<payload_b32>-<signature_b32>`. 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<String>,
}
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<Self> {
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<Self> {
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<Self> {
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<u8>,
/// 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<Self> {
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());
}
}
+51
View File
@@ -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,
};
+353
View File
@@ -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<Self> {
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<String> {
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<ValidateResponse> {
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<ValidateResponse> {
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<MachineResponse> {
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<MachineResponse> {
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<MachineResponse> {
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<MachineResponse> {
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<PurchaseSession> {
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<PollResponse> {
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<String>,
/// License id on success.
#[serde(default)]
pub license_id: Option<String>,
/// Product id on success.
#[serde(default)]
pub product_id: Option<String>,
/// Product slug on success.
#[serde(default)]
pub product_slug: Option<String>,
/// Issue timestamp (RFC 3339) on success.
#[serde(default)]
pub issued_at: Option<String>,
/// Expiry timestamp (RFC 3339) if the license has one.
#[serde(default)]
pub expires_at: Option<String>,
/// End of the grace window (RFC 3339) if the license is currently in
/// its grace period.
#[serde(default)]
pub grace_until: Option<String>,
/// 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<bool>,
/// True if this license is flagged as a trial.
#[serde(default)]
pub is_trial: Option<bool>,
/// Entitlement slugs granted by the license.
#[serde(default)]
pub entitlements: Vec<String>,
/// License status string (`active`, `suspended`, `revoked`).
#[serde(default)]
pub status: Option<String>,
/// The id of the machine row this call activated or matched (when a
/// fingerprint was supplied).
#[serde(default)]
pub machine_id: Option<String>,
/// The license's seat cap. `0` = unlimited, `1` = single-seat, `n` =
/// n-seat.
#[serde(default)]
pub max_machines: Option<i64>,
}
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<String>,
/// The machine id, when present.
#[serde(default)]
pub machine_id: Option<String>,
/// Current count of active machines for this license.
#[serde(default)]
pub active_count: Option<i64>,
/// Seat cap for this license (echo of `max_machines`).
#[serde(default)]
pub max_machines: Option<i64>,
}
/// 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<String>,
/// Populated with the license row's id once issued.
pub license_id: Option<String>,
}
+36
View File
@@ -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<Self> {
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<Self> {
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 })
}
}
+122
View File
@@ -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<VerifyOk> {
let parsed = LicenseKey::parse(key)?;
self.verify_parsed(&parsed)
}
/// Verify an already-parsed key.
pub fn verify_parsed(&self, key: &LicenseKey) -> Result<VerifyOk> {
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<VerifyOk> {
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<VerifyOk> {
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
}