Initial public commit
This commit is contained in:
+15
@@ -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
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user