Sequence Diagram: License Validation Flow
Purpose: Complete license validation workflow from CODITECT client startup through license token generation and local verification.
Actors:
- CODITECT Client (local application)
- License API (Django on GKE)
- Identity Platform (Google OAuth2)
- PostgreSQL (Cloud SQL)
- Cloud KMS (license signing)
Flow: Check-on-start pattern with offline-capable signed tokens
Mermaid Sequence Diagram
Step-by-Step Breakdown
1. Authentication (Steps 1-2)
Client initiates authentication:
# Client-side: Authentication
import requests
def authenticate(email: str, password: str) -> str:
"""
Authenticate with Identity Platform.
Returns JWT access token (1 hour expiry).
"""
response = requests.post(
'https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword',
params={'key': API_KEY},
json={
'email': email,
'password': password,
'returnSecureToken': True
}
)
if response.status_code != 200:
raise AuthenticationError('Invalid credentials')
data = response.json()
return data['idToken'] # JWT token
Generate Hardware ID:
# Client-side: Hardware fingerprinting
import hashlib
import uuid
import platform
import subprocess
def generate_hardware_id() -> str:
"""
Generate stable hardware ID for device identification.
Components:
- CPU model (stable across reboots)
- Disk serial number (stable)
- MAC address (stable)
Returns SHA256 hash for privacy.
"""
# Get CPU model
cpu = platform.processor()
# Get disk serial (macOS example)
disk_serial = subprocess.check_output(
['diskutil', 'info', '/'],
text=True
)
# Parse disk serial from output
# Get MAC address
mac = ':'.join(['{:02x}'.format((uuid.getnode() >> elements) & 0xff)
for elements in range(0,8*6,8)][::-1])
# Combine and hash
hardware_string = f"{cpu}|{disk_serial}|{mac}"
hardware_id = hashlib.sha256(hardware_string.encode()).hexdigest()
return hardware_id
2. License Validation Request (Step 3)
Client sends validation request:
# Client-side: License validation
def validate_license(
jwt_token: str,
license_key: str,
hardware_id: str,
version: str
) -> dict:
"""
Validate license with cloud API.
Returns signed license token if valid.
"""
response = requests.post(
'https://api.coditect.ai/api/v1/license/validate',
headers={
'Authorization': f'Bearer {jwt_token}',
'Content-Type': 'application/json'
},
json={
'license_key': license_key,
'hardware_id': hardware_id,
'version': version
}
)
if response.status_code != 200:
raise LicenseValidationError(response.json()['error'])
return response.json()
3. API Processing (Steps 4-9)
Server-side: License validation endpoint:
# Server-side: Django REST Framework endpoint
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework import serializers
from django.utils import timezone
from datetime import timedelta
from apps.licenses.models import License, DeviceActivation, Tier
from apps.tenants.context import get_current_tenant
class LicenseValidationSerializer(serializers.Serializer):
"""Serializer for license validation request."""
license_key = serializers.CharField(max_length=255)
hardware_id = serializers.CharField(max_length=255)
version = serializers.CharField(max_length=50)
class LicenseValidationResponseSerializer(serializers.Serializer):
"""Serializer for license validation response."""
license_token = serializers.DictField()
valid_until = serializers.DateTimeField()
class LicenseViewSet(viewsets.GenericViewSet):
"""License validation and management endpoints."""
permission_classes = [IsAuthenticated]
@action(detail=False, methods=['post'])
def validate(self, request):
"""
Validate license and return signed token.
Validation steps:
1. JWT validation (via IsAuthenticated permission)
2. License lookup in database (tenant-scoped)
3. Active/expiry checks
4. Version compatibility check
5. Device limit check (if applicable)
6. Sign license token with Cloud KMS
"""
# Step 1: JWT already validated by IsAuthenticated permission
user = request.user
tenant = get_current_tenant()
# Validate request data
serializer = LicenseValidationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
license_key = serializer.validated_data['license_key']
hardware_id = serializer.validated_data['hardware_id']
version = serializer.validated_data['version']
# Step 2: Look up license (automatic tenant filtering via TenantModel)
try:
license_obj = License.objects.get(license_key=license_key)
except License.DoesNotExist:
return Response(
{'error': 'License not found'},
status=status.HTTP_404_NOT_FOUND
)
# Step 3: Validation checks
if not license_obj.is_active:
return Response(
{'error': 'License inactive'},
status=status.HTTP_403_FORBIDDEN
)
if license_obj.is_expired:
return Response(
{'error': 'License expired'},
status=status.HTTP_403_FORBIDDEN
)
# Verify tenant matches user (redundant with RLS, but explicit check)
if license_obj.tenant_id != tenant.id:
return Response(
{'error': 'License not owned by user'},
status=status.HTTP_403_FORBIDDEN
)
# Step 4: Version compatibility
if not license_obj.is_version_allowed(version):
return Response(
{
'error': f'Version {version} not allowed for {license_obj.tier} tier'
},
status=status.HTTP_403_FORBIDDEN
)
# Step 5: Device limit check (Team/Enterprise only)
if license_obj.tier in [Tier.TEAM, Tier.ENTERPRISE]:
activation_count = DeviceActivation.objects.filter(
license=license_obj,
is_active=True
).count()
device_limit = license_obj.get_device_limit()
if activation_count >= device_limit:
# Check if this hardware_id is already activated
existing = DeviceActivation.objects.filter(
license=license_obj,
hardware_id=hardware_id,
is_active=True
).first()
if not existing:
return Response(
{
'error': f'Device limit ({device_limit}) exceeded',
'active_devices': activation_count,
'limit': device_limit
},
status=status.HTTP_403_FORBIDDEN
)
# Step 6: Build license payload
valid_until = timezone.now() + timedelta(hours=24)
license_payload = {
'license_key': license_obj.license_key,
'tier': license_obj.tier,
'expires_at': license_obj.expires_at.isoformat(),
'max_seats': license_obj.max_seats,
'allowed_versions': license_obj.get_allowed_versions(),
'hardware_id': hardware_id,
'issued_at': timezone.now().isoformat(),
'valid_until': valid_until.isoformat()
}
# Step 7: Sign with Cloud KMS
from apps.licenses.services import sign_license_token
signature = sign_license_token(license_payload)
license_token = {
**license_payload,
'signature': signature
}
# Serialize response
response_serializer = LicenseValidationResponseSerializer({
'license_token': license_token,
'valid_until': valid_until
})
return Response(response_serializer.data, status=status.HTTP_200_OK)
Cloud KMS signing:
# apps/licenses/services.py - Cloud KMS integration
from google.cloud import kms
import hashlib
import base64
import json
def sign_license_token(payload: dict) -> str:
"""
Sign license token with Cloud KMS RSA-4096 key.
Private key never leaves Cloud HSM.
"""
# Serialize payload (canonical JSON for consistent hashing)
payload_json = json.dumps(payload, sort_keys=True)
# Hash payload (SHA256)
digest = hashlib.sha256(payload_json.encode()).digest()
# Sign with Cloud KMS
client = kms.KeyManagementServiceClient()
key_name = (
'projects/coditect-cloud-infra/locations/global/'
'keyRings/license-signing/cryptoKeys/license-key/'
'cryptoKeyVersions/1'
)
digest_dict = {'sha256': digest}
response = client.asymmetric_sign(
request={
'name': key_name,
'digest': digest_dict
}
)
# Base64 encode signature
signature = base64.b64encode(response.signature).decode()
return signature
4. Client Verification (Steps 10-13)
Client-side: Signature verification:
# Client-side: Verify license signature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
import base64
import json
def verify_license_signature(license_token: dict) -> bool:
"""
Verify license token signature using KMS public key.
Can be done offline - no network required.
"""
# Extract signature
signature = base64.b64decode(license_token['signature'])
# Reconstruct payload (without signature)
payload = {k: v for k, v in license_token.items() if k != 'signature'}
payload_json = json.dumps(payload, sort_keys=True)
# Load KMS public key (cached locally)
public_key_pem = load_kms_public_key() # From local cache
public_key = serialization.load_pem_public_key(
public_key_pem.encode(),
backend=default_backend()
)
# Verify signature
try:
public_key.verify(
signature,
payload_json.encode(),
padding.PKCS1v15(),
hashes.SHA256()
)
return True
except Exception:
return False
Cache license locally:
# Client-side: Cache license
import os
import json
def cache_license_locally(license_token: dict):
"""
Cache validated license token for offline use.
Location: ~/.coditect/license.json
"""
license_cache_path = os.path.expanduser('~/.coditect/license.json')
os.makedirs(os.path.dirname(license_cache_path), exist_ok=True)
with open(license_cache_path, 'w') as f:
json.dump(license_token, f, indent=2)
print(f"License cached at {license_cache_path}")
Error Scenarios
Invalid Credentials
License Expired
Device Limit Exceeded
Invalid Signature
Performance Characteristics
Latency Breakdown:
| Step | Operation | Latency | Notes |
|---|---|---|---|
| 1 | Authentication (Identity Platform) | ~200ms | First time only, cached |
| 2 | Hardware ID generation | ~50ms | Local CPU/disk/MAC read |
| 3 | API request (network) | ~100ms | Depends on location |
| 4 | JWT validation | ~10ms | Local Redis cache lookup |
| 5 | Database query | ~5ms | Indexed lookup |
| 6 | Validation checks | ~2ms | In-memory |
| 7 | Device limit query | ~5ms | Conditional |
| 8 | Cloud KMS signing | ~50ms | HSM operation |
| 9 | Build token | ~1ms | JSON serialization |
| 10 | Signature verification | ~10ms | Local RSA verification |
| 11 | Cache write | ~5ms | Local file write |
Total: ~438ms (first time), ~438ms (cached auth)
Offline Mode:
Once license cached and signature verified, CODITECT can run offline until valid_until expires (24 hours default).
Security Considerations
Threat Model:
-
Man-in-the-Middle (MITM):
- Mitigation: TLS 1.3 for all API calls
- Certificate pinning in client
-
License Tampering:
- Mitigation: Cloud KMS RSA-4096 signature
- Client verifies signature before accepting
- Tampering detected immediately
-
Token Theft:
- Mitigation: JWT short-lived (1 hour)
- Refresh tokens rotated
- Hardware ID binding prevents use on other devices
-
Replay Attacks:
- Mitigation:
issued_attimestamp in license token valid_untilexpiry enforced- Nonce could be added for extra protection
- Mitigation:
-
Credential Stuffing:
- Mitigation: Rate limiting on authentication endpoint
- Account lockout after 5 failed attempts
- CAPTCHA on repeated failures
Related Documentation
- ADR-001: License Key Generation
- ADR-003: Seat Management
- ADR-007: Hardware Fingerprinting
- ADR-016: Version-Based Licensing
- ADR-020: Security Hardening
Last Updated: 2025-11-30 Diagram Type: Sequence (Mermaid) Scope: Core licensing flow