Skip to content

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):

{prefix}_{tenant}_{product}_{env}_{random_hex}_{checksum}

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

  1. Client sends fingerprint: The device_fingerprint field in the validation request identifies the device.
  2. Auto-registration: If the fingerprint is new, the server registers the device (checking activation limits).
  3. Activation limits: If current_activations >= max_activations, validation returns HTTP 403.
  4. Last-seen tracking: Existing devices get their last_seen_at updated on every validation.
  5. JWT includes fingerprint: The issued JWT contains device_fingerprint in 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_keys table on startup via all_hashes()
  • No false negatives: if bloom says "not revoked", it's definitely not revoked
  • Backed by revoked_keys DB tombstone table (append-only, SHA-256 hashes)

5. Revocation Pipeline (3 Layers)

When validate() is called, revocation is checked in order:

  1. Bloom filter (fastest) — if maybe_revoked() returns false, skip DB check
  2. Redis cache — check revoked:{license_id} key
  3. DB tombstonerevoked_keys table 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 operation
  • Err(Error::LicenseInvalid) - Invalid license
  • Err(Error::LicenseExpired) - License expired
  • Err(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:

cargo test --release --lib services::license::tests

Performance

  • Simple validation: < 1ms
  • Enhanced validation: < 5ms (with Redis)
  • JWT generation: < 2ms
  • Token verification: < 1ms

Security Considerations

  1. JWT Secret: Use strong, random secret (min 32 bytes)
  2. Device Fingerprinting: Combine multiple hardware identifiers
  3. Key Rotation: Regularly rotate signing keys
  4. Revocation TTL: Set appropriate cache TTL (default: 1 hour)
  5. 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_fingerprint can register under a different license
  • Each license tracks its own current_activations / max_activations independently

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:

  1. The stripe_price_id is extracted from the webhook payload
  2. The stripe_product_mappings table maps it to a Sawabona product
  3. The first active plan for that product determines max_activations
  4. A license key is generated and the license is created with status active
  5. 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 via cache_dir)
  • sawabona-sdk-ts: OS-specific app data directory (configurable via cacheDir)
  • sawabona-sdk-rust: JSON files by default (cache-json feature), or sled embedded DB (cache-sled feature 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 reports distinct_minutes and distinct_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: ApiKey admin 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}