License Validation Service Guide¶
Overview¶
The LicenseService provides comprehensive license validation with support for:
- Basic validation: Status, expiration, and product matching
- Enhanced validation: Cryptographic validation with device binding
- Multi-version key support: Key rotation with adoption metrics
- Revocation caching: Redis-backed revocation list with TTL
- JWT token generation: Secure token creation and verification
- Device fingerprinting: Hardware-based license binding
- Structured license keys: Human-readable keys with tenant, product, environment, and CRC-32 checksum
License Key Format¶
Keys follow the format (always 6 segments):
The fixed saw_ prefix leads every key, acting as a recognisable magic
prefix (similar to Stripe's sk_live_).
Components¶
| Segment | Description | Example |
|---|---|---|
prefix |
Product-family prefix | saw |
tenant |
Organisation/customer slug, [a-z0-9-] — no _ |
default-tenant |
product |
Short product slug | jag, lis, asm |
env |
Environment tag | live or test |
random_hex |
Cryptographically-random hex (length set by SAWABONA_LICENSE__KEY_LENGTH, default 20) |
a1b2c3d4e5f6a7b8c9d0 |
checksum |
First 4 hex chars of CRC-32 over preceding parts | f0e1 |
Examples¶
saw_default-tenant_jag_live_a1b2c3d4e5f6a7b8c9d0_f0e1 # Jagora Pro (live)
saw_default-tenant_lis_test_9c8d7e6f5a4b3c2d1e0f_ab12 # Lisaba (test)
saw_default-tenant_saw_live_a1b2c3d4e5f6a7b8c9d0_1234 # Sawabona itself (always 6 parts)
saw_acme_jag_live_a1b2c3d4e5f6a7b8c9d0_5678 # Custom tenant "acme"
Configuration¶
Environment variables (via SAWABONA_ prefix + __ nesting):
| Variable | Default | Description |
|---|---|---|
SAWABONA_LICENSE__PREFIX |
saw |
Product-family prefix |
SAWABONA_LICENSE__KEY_LENGTH |
20 |
Random hex chars (even, 16..=512) |
SAWABONA_TENANCY__DEFAULT_TENANT |
default-tenant (from DEFAULT_TENANT_ID) |
Default tenant slug (lives under the tenancy config table, not license) |
Checksum Validation¶
Keys include a CRC-32 checksum (IEEE / ISO 3309) for typo detection:
use sawabona_core::key_generation::validate_checksum;
let is_valid = validate_checksum("saw_default-tenant_jag_live_a1b2c3d4e5f6a7b8c9d0_f0e1");
// true if checksum matches, false if corrupted/typo
Device Binding¶
Device binding ties a license to specific hardware/installations. It is wired into the /api/v1/licenses/validate endpoint.
How It Works¶
- Client sends fingerprint: The
device_fingerprintfield in the validation request identifies the device. - Auto-registration: If the fingerprint is new, the server registers the device (checking activation limits).
- Activation limits: If
current_activations >= max_activations, validation returns HTTP 403. - Last-seen tracking: Existing devices get their
last_seen_atupdated on every validation. - JWT includes fingerprint: The issued JWT contains
device_fingerprintin its claims.
Device Management Endpoints¶
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/licenses/{license_id}/devices |
List devices for a license |
POST |
/api/v1/devices/{id}/deactivate |
Deactivate a device (frees an activation slot) |
DELETE |
/api/v1/devices/{id} |
Delete a device permanently |
Device Fields¶
| Field | Type | Description |
|---|---|---|
fingerprint |
String | Unique hardware identifier (required) |
name |
String | Human-readable device name (optional, auto-generated) |
device_type |
String | desktop, mobile, server, etc. (optional) |
os |
String | Operating system (optional) |
os_version |
String | OS version (optional) |
is_active |
Boolean | Whether the device counts toward activation limit |
Architecture¶
Core Components¶
1. LicenseService¶
Main service for license validation with two modes:
validate_simple(): Fast validation (status, expiration)
Full validation is NOT a core method — it lives in the API handler
validate_license (sawabona-api/src/handlers/licenses.rs), which is the only
path the product uses. It does everything a core-level helper could, plus the
parts that make a token trustworthy: the geometric-proof challenge, the plan's
canonical entitlements, the Ed25519-signed envelope a client can verify against
a pinned key, and the plan slug + tier.
2. MultiVersionValidator¶
Handles cryptographic validation with key rotation:
- Multiple key versions support
- Adoption metrics tracking
- Signature verification with fallback to older keys
3. RevocationCache (Redis)¶
Redis-backed revocation list:
- Fast revocation checks via
revoked:{license_id}keys - Configurable TTL (default: 1 hour)
- Async operations
4. RevokedKeyBloomFilter (In-Memory)¶
Probabilistic fast-rejection filter:
- O(1) lookup — no DB query needed for most checks
- Loaded from
revoked_keystable on startup viaall_hashes() - No false negatives: if bloom says "not revoked", it's definitely not revoked
- Backed by
revoked_keysDB tombstone table (append-only, SHA-256 hashes)
5. Revocation Pipeline (3 Layers)¶
When validate() is called, revocation is checked in order:
- Bloom filter (fastest) — if
maybe_revoked()returns false, skip DB check - Redis cache — check
revoked:{license_id}key - DB tombstone —
revoked_keystable confirms revocation via SHA-256 hash lookup
The license row itself also has a status field (active, revoked, expired, suspended).
Revocation does NOT delete the license or its devices — it sets status to revoked and creates a tombstone in revoked_keys.
4. LicenseClaims¶
JWT claims structure:
- License ID, Product ID
- Device fingerprint (optional)
- Issued at and expiration timestamps
Usage Examples¶
Basic Validation¶
use sawabona_core::prelude::*;
let service = LicenseService::new("jwt-secret".to_string());
let license = License::new(
"key-123".to_string(),
product_id,
plan_id,
5, // max activations
30, // duration days
);
let result = service.validate_simple(&license).await;
if result.is_valid {
println!("License is valid!");
}
Enhanced Validation with Device Binding¶
let device = Device::new(
license.id,
"MacBook Pro".to_string(),
"fingerprint-abc123".to_string(),
"desktop".to_string(),
);
// Full validation goes through the API, not the core service: POST
// /api/v1/licenses/validate (handler `validate_license`). It is the only
// path that returns a signed entitlement envelope, and the only one that
// carries the plan slug and tier.
JWT Token Verification¶
let token = result.token.unwrap();
let claims = service.verify_token(&token)?;
// Verify device fingerprint
let is_valid_device = service.validate_device_fingerprint(
&claims,
"fingerprint-abc123"
)?;
Multi-Version Key Validation¶
let mut validator = MultiVersionValidator::new(1);
validator.add_key(1, "key-v1".to_string());
validator.add_key(2, "key-v2".to_string());
let version = validator.validate_signature(data, signature)?;
let metrics = validator.get_metrics(version);
println!("Validation count: {}", metrics.unwrap().validation_count);
Revocation Cache¶
let cache = RevocationCache::new(redis_conn, 3600).await?;
// Check if revoked
let is_revoked = cache.is_revoked(&license_id.to_string()).await?;
// Revoke a license
cache.revoke(&license_id.to_string()).await?;
Validation Modes¶
Simple Mode¶
- ✅ Status check (Active/Expired/Suspended/Revoked)
- ✅ Expiration check
- ❌ Revocation check
- ❌ Device binding
- ❌ JWT token
Enhanced Mode¶
- ✅ Status check
- ✅ Expiration check
- ✅ Revocation check (if cache configured)
- ✅ Device binding (if device provided)
- ✅ JWT token generation
Error Handling¶
All operations return Result<T> which can be:
Ok(value)- Successful operationErr(Error::LicenseInvalid)- Invalid licenseErr(Error::LicenseExpired)- License expiredErr(Error::RedisError)- Cache operation failed
Testing¶
14 comprehensive tests cover:
- License service creation
- Simple validation (active/expired)
- Enhanced validation (with/without device)
- JWT token generation and verification
- Device fingerprint validation
- Multi-version validator
- Claims serialization
Run tests:
Performance¶
- Simple validation: < 1ms
- Enhanced validation: < 5ms (with Redis)
- JWT generation: < 2ms
- Token verification: < 1ms
Security Considerations¶
- JWT Secret: Use strong, random secret (min 32 bytes)
- Device Fingerprinting: Combine multiple hardware identifiers
- Key Rotation: Regularly rotate signing keys
- Revocation TTL: Set appropriate cache TTL (default: 1 hour)
- Token Expiration: Default 24 hours (configurable)
Integration¶
Add to your service:
use sawabona_core::prelude::*;
let service = LicenseService::new(env::var("JWT_SECRET")?);
let service = service.with_revocation_cache(cache);
License Portability¶
A device whose license was revoked can activate on a new license without issue. The device-to-license binding is per-license, not global:
- Device records on the revoked license remain in the DB (audit trail)
- The same
device_fingerprintcan register under a different license - Each license tracks its own
current_activations/max_activationsindependently
This supports real-world scenarios like license upgrades, policy changes, or customer re-purchases.
Automatic License Creation from Payments¶
When a Stripe payment webhook (checkout.session.completed or invoice.payment_succeeded) is received and verified, a license is automatically created:
- The
stripe_price_idis extracted from the webhook payload - The
stripe_product_mappingstable maps it to a Sawabona product - The first active plan for that product determines
max_activations - A license key is generated and the license is created with status
active - The webhook response includes the new
license_id
This is tested end-to-end in sawabona-core/tests/e2e_stripe_payment_test.rs.
Offline Mode (Client-Side)¶
Offline validation with cached keys is an existing client-side feature available in sawabona-sdk-rust, sawabona-sdk-py, and sawabona-sdk-ts. It works as follows:
Grace Period¶
When the license server is unreachable, SDK clients fall back to a locally cached validation token for a configurable grace period (default: 72 hours in the Python and TypeScript SDKs). After the grace period expires, validation fails until connectivity is restored.
Cache Location¶
- sawabona-sdk-py:
~/.sawabona_license_cache/(configurable viacache_dir) - sawabona-sdk-ts: OS-specific app data directory (configurable via
cacheDir) - sawabona-sdk-rust: JSON files by default (
cache-jsonfeature), or sled embedded DB (cache-sledfeature flag)
HMAC Integrity Protection¶
Cached tokens are sealed with an HMAC derived from the license key. On load, the HMAC is verified before the cached data is trusted. If integrity verification fails, the corrupted cache is automatically cleared and a fresh online validation is required.
Delta Usage Tracking¶
While offline, usage events are tracked locally. When connectivity is restored, accumulated deltas are synced to the server on the next successful validation.
For SDK-specific configuration, see:
Future Enhancements¶
- [ ] Rate limiting for validation attempts
- [ ] Audit logging for all validations
- [ ] Metrics export (Prometheus)
Licence audit trail¶
license_audit_log records what happened to a licence: validate, activate,
revoke, rotate, transfer, offline_token, and unknown_key. Mutating
acts are recorded where they all converge, before the outgoing webhook is
enqueued and independently of it — an unreachable subscriber must not cost the
record that the act happened.
Reads are scoped by product: several vendors share this engine, and which licences validate, from where and how often is competitive information.
Two things to know before reading counts:
- A validation is not a login. Consumers cache a validation result (lisaba's tier resolver: 300 s), so six sign-ins in five minutes reach the engine once.
- Retries inflate rows. A client SDK retries a failed validation; four human
attempts wrote 56 rows in production. Judge a sweep on
/api/v1/audit/unresolved/summary, which groups by key and reportsdistinct_minutesanddistinct_ips— retries collapse into one minute, and one key tried from many addresses is the loudest row rather than the quietest.
Attempts that matched no licence are not attributed to a product. The only thing naming a product on an invented key is the segment the caller typed, and the caller is the party under suspicion; attributing on it would let anyone write rows into anyone's trail. They surface at operator scope instead.
Revocation invalidates consumer caches: the outgoing license.revoked event
carries key_hash, the same SHA-256 digest lisaba's resolver uses as its cache
key, so a gateway drops exactly that licence without the plaintext ever crossing
the wire.
Read Tokens & Webhook Endpoints (admin)¶
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/v1/admin/read-tokens |
Admin | Create a scoped read token |
| GET | /api/v1/admin/read-tokens |
Admin | List read tokens |
| DELETE | /api/v1/admin/read-tokens/{id} |
Admin | Revoke a read token |
| POST | /api/v1/webhook-endpoints |
Admin | Register an outgoing webhook endpoint |
| GET | /api/v1/webhook-endpoints |
Admin | List webhook endpoints |
| DELETE | /api/v1/webhook-endpoints/{id} |
Admin | Delete a webhook endpoint |
Payment Webhooks (inbound)¶
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/v1/webhooks/{provider} |
Signature-verified / public | Handle inbound provider webhook |
Supported providers: stripe, flutterwave, paystack, paddle, mollie
Payment Administration¶
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/admin/payments/providers |
Admin | List providers + status |
| GET | /api/v1/admin/payments/providers/{provider} |
Admin | Get provider config |
| PUT | /api/v1/admin/payments/providers/{provider} |
Admin | Update provider config |
| POST | /api/v1/admin/payments/providers/{provider}/test |
Admin | Test provider health |
Note: Public/Proof/Signature routes are the bootstrap + machine-to-machine paths a client uses before (or without) a token. Admin routes require the
Authorization: ApiKeyadmin credential.
License Validation Flow¶
Clients obtain a JWT through a two-step challenge-response protocol:
Step 1 — Request a challenge
POST /api/v1/geometric-proof/challenge
Body: { "license_key", "product_slug", "device_hash" }
Response: { "challenge_id", "seed", "ops", "proof_version", "require_gpv", "expires_at" }
ops is an array of { "op_type", "params" } objects. Each op's JSON
serialization is canonical (sorted keys, compact separators) and must be hashed
verbatim by the client when computing the proof.
Step 2 — Submit proof and receive token
POST /api/v1/licenses/validate
Body: { "license_key", "product_slug", "client_id", "proof": { "challenge_id", "proof" } }
Response: { "token", "features", "expires_at", "permit", "quota", "device_count", "device_limit" }
sequenceDiagram
participant C as Client
participant A as sawabona-api
C->>A: POST /api/v1/geometric-proof/challenge<br/>{license_key, product_slug, device_hash}
A-->>C: {challenge_id, seed, ops, proof_version, require_gpv, expires_at}
Note over C: Compute proof from seed + ops
C->>A: POST /api/v1/licenses/validate<br/>{license_key, product_slug, client_id, proof:{challenge_id, proof}}
A-->>C: {token, features, expires_at, permit, quota, device_count, device_limit}