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
+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
}