ADR-017: Device-Based Activation Limits
Status: Accepted Date: 2025-11-30 Deciders: Product Team, Security Team, Engineering Team Tags: device-activation, hardware-id, security, abuse-prevention
Context
Device Activation Abuse Problem
Individual licenses (Pro tier) are designed for single developers but can be abused by sharing across multiple devices:
Abuse Scenarios:
- License Sharing: Developer shares Pro license with 5 teammates via shared credentials
- CI/CD Abuse: License used on 20+ build agents simultaneously
- Credential Theft: Stolen license key used on attacker's machines
Business Impact:
- Revenue Leakage: 1 Pro license ($58/month) shared across 5 devices = $232/month lost revenue
- Support Burden: "Why is my license locked out?" when legitimate device blocked due to abuse
- Competitive Disadvantage: Competitors enforce device limits, we don't
Real-World Scenario
Alice (Pro License - $58/month)
- Device 1: MacBook Pro (work laptop) ✅
- Device 2: iMac (home desktop) ✅
- Device 3: Shared license with Bob (laptop) ❌ ABUSE
- Device 4: Shared license with Carol (laptop) ❌ ABUSE
- Device 5: Shared license with Dave (desktop) ❌ ABUSE
Current System (No Device Limits):
- All 5 devices work simultaneously
- 5 developers using 1 Pro license
- Revenue loss: $232/month
- Alice's account: No detection
Proposed System (Device Limits):
- Pro tier: 3 device activations
- Device 1, 2 active ✅
- Device 3 tries to activate → WARNING (3rd device, last slot)
- Device 4 tries to activate → DENIED (4th device, limit exceeded)
- Alice receives email: "Device limit reached (3/3), deactivate unused devices"
Device Limit Requirements
Free Tier:
- 1 device activation (single machine only)
- No deactivation allowed (permanent binding)
Pro Tier:
- 3 device activations (work + home + backup)
- Self-service deactivation (device management dashboard)
- 30-day cooldown between deactivations (prevent rapid cycling)
Team Tier:
- Floating licenses (no per-user device limit)
- Seat-based limits instead of device limits
Enterprise Tier:
- Unlimited device activations per user
- Centralized device management
- Admin-controlled deactivation
Technical Challenges
Hardware ID Generation:
- Must be stable across reboots
- Must change if hardware changes (prevent cloning)
- Cross-platform (macOS, Linux, Windows)
Device Fingerprinting:
- Combine multiple hardware attributes (CPU, disk, MAC address)
- Hash for privacy (don't store raw hardware IDs)
VM Detection:
- Docker containers shouldn't count as separate devices
- Detect ephemeral environments (CI/CD runners)
Offline Support:
- Device activation must work offline after initial activation
- Deactivation requires online connection
Decision
We will implement device-based activation limits with:
- Hardware Fingerprinting (CPU ID + Disk Serial + MAC Address → SHA256)
- Tier-Based Device Limits (Free: 1, Pro: 3, Team: unlimited)
- Self-Service Device Management (deactivation dashboard, 30-day cooldown)
- CI/CD Detection (exempt ephemeral environments from device limits)
- Offline Grace Period (72 hours for Pro, 24 hours for Free)
Device Activation Architecture
┌────────────────────────────────────────────────────────────────┐
│ Device Activation Flow │
└────────────────────────────────────────────────────────────────┘
Developer Installs CODITECT on New Device
│
│ 1. Generate Hardware ID
│ CPU: Intel Core i7-9750H
│ Disk: APPLE SSD AP0512M
│ MAC: 00:1A:2B:3C:4D:5E
│ → SHA256 hash
▼
┌───────────────────────┐
│ hardware_id = │
│ sha256(...) = │
│ "a1b2c3d4..." │
└───────┬───────────────┘
│
│ 2. POST /api/v1/devices/activate
│ {
│ "license_key": "LIC-...",
│ "hardware_id": "a1b2c3d4...",
│ "device_name": "MacBook Pro",
│ "os": "macOS 14.0"
│ }
▼
┌───────────────────────┐
│ License API │
│ │
│ Check: │
│ - License valid? │
│ - Device limit? │
│ - Already activated? │
└───────┬───────────────┘
│
│ Query: device_activations
│ WHERE license_id = ... AND hardware_id = "a1b2c3d4..."
▼
┌───────────────────────┐
│ Device Check │
│ │
│ Already activated? │
│ → YES: Reactivate │
│ → NO: Check limit │
└───────┬───────────────┘
│
│ NOT already activated
│ Count active devices for license
▼
┌───────────────────────┐
│ Limit Check │
│ │
│ Active devices: 2 │
│ Device limit: 3 │
│ 2 < 3? → ALLOWED │
└───────┬───────────────┘
│
│ Create activation record
▼
┌───────────────────────┐
│ INSERT INTO │
│ device_activations │
│ │
│ license_id, │
│ hardware_id, │
│ device_name, │
│ activated_at, │
│ status: active │
└───────┬───────────────┘
│
│ Success response
▼
┌───────────────────────┐
│ { │
│ "activated": true, │
│ "device_id": "...",│
│ "devices_used": 3, │
│ "devices_limit": 3,│
│ "warning": │
│ "Last slot used" │
│ } │
└───────────────────────┘
Device Deactivation Flow
User Wants to Deactivate Old Laptop
│
│ GET /api/v1/devices
│ → List all activated devices
▼
┌───────────────────────┐
│ Device List │
│ │
│ 1. MacBook Pro │
│ (active) │
│ 2. iMac │
│ (active) │
│ 3. Old Laptop │
│ (active, unused) │
└───────┬───────────────┘
│
│ DELETE /api/v1/devices/{device_id}
│ → Deactivate Old Laptop
▼
┌───────────────────────┐
│ Cooldown Check │
│ │
│ Last deactivation: │
│ 25 days ago │
│ │
│ Cooldown: 30 days │
│ 25 < 30? │
│ → DENIED │
└───────┬───────────────┘
│
│ Cooldown not elapsed
▼
┌───────────────────────┐
│ Error Response │
│ │
│ { │
│ "error": │
│ "cooldown", │
│ "days_remaining": │
│ 5, │
│ "message": │
│ "Can deactivate │
│ in 5 days" │
│ } │
└───────────────────────┘
Implementation
1. Database Schema
File: backend/licenses/models.py
from django.db import models
from django.utils import timezone
from datetime import timedelta
import uuid
class DeviceActivation(models.Model):
"""
Track device activations per license.
Each device gets unique hardware_id (SHA256 hash of hardware attributes).
"""
class Status(models.TextChoices):
ACTIVE = 'active', 'Active'
DEACTIVATED = 'deactivated', 'Deactivated'
REVOKED = 'revoked', 'Revoked' # Admin-forced deactivation
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
license = models.ForeignKey('License', on_delete=models.CASCADE, related_name='device_activations')
tenant = models.ForeignKey('Tenant', on_delete=models.CASCADE)
# Device identification
hardware_id = models.CharField(max_length=64, db_index=True)
# SHA256 hash of hardware attributes (CPU + Disk + MAC)
device_name = models.CharField(max_length=255)
# User-friendly name (e.g., "MacBook Pro", "Work Desktop")
# Device metadata
os_name = models.CharField(max_length=50) # macOS, Linux, Windows
os_version = models.CharField(max_length=50)
hostname = models.CharField(max_length=255, blank=True)
# Activation tracking
status = models.CharField(max_length=20, choices=Status.choices, default=Status.ACTIVE)
activated_at = models.DateTimeField(auto_now_add=True)
last_seen_at = models.DateTimeField(auto_now=True)
deactivated_at = models.DateTimeField(null=True, blank=True)
# Deactivation metadata
deactivation_reason = models.CharField(max_length=255, blank=True)
# "user_requested", "admin_revoked", "license_expired"
class Meta:
db_table = 'device_activations'
ordering = ['-activated_at']
unique_together = [['license', 'hardware_id']]
indexes = [
models.Index(fields=['license', 'status']),
models.Index(fields=['hardware_id', 'status']),
]
def __str__(self):
return f"{self.device_name} ({self.hardware_id[:8]}...) - {self.status}"
class DeviceLimit(models.Model):
"""
Device activation limits per tier.
"""
tier = models.OneToOneField('Tier', on_delete=models.CASCADE, related_name='device_limit')
max_devices = models.IntegerField()
# Free: 1, Pro: 3, Team: unlimited (9999), Enterprise: unlimited
allow_deactivation = models.BooleanField(default=True)
# Free: False (permanent binding), Pro: True
deactivation_cooldown_days = models.IntegerField(default=30)
# Days between deactivations (prevent rapid cycling)
class Meta:
db_table = 'device_limits'
def __str__(self):
return f"{self.tier.name}: {self.max_devices} devices"
class DeviceDeactivationLog(models.Model):
"""
Log of device deactivations for audit trail.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
device = models.ForeignKey('DeviceActivation', on_delete=models.CASCADE)
license = models.ForeignKey('License', on_delete=models.CASCADE)
tenant = models.ForeignKey('Tenant', on_delete=models.CASCADE)
deactivated_at = models.DateTimeField(auto_now_add=True)
reason = models.CharField(max_length=255)
# Who initiated deactivation
initiated_by = models.CharField(max_length=50)
# "user", "admin", "system"
class Meta:
db_table = 'device_deactivation_logs'
ordering = ['-deactivated_at']
class License(models.Model):
"""Extended License model with device tracking."""
# ... existing fields ...
# Device management
last_deactivation_at = models.DateTimeField(null=True, blank=True)
# Track last deactivation for cooldown enforcement
@property
def active_device_count(self) -> int:
"""Get count of active device activations."""
return self.device_activations.filter(status=DeviceActivation.Status.ACTIVE).count()
@property
def device_limit(self) -> int:
"""Get device limit for this license's tier."""
try:
return self.tier.device_limit.max_devices
except DeviceLimit.DoesNotExist:
return 1 # Default: 1 device
@property
def can_activate_new_device(self) -> bool:
"""Check if new device can be activated."""
return self.active_device_count < self.device_limit
def can_deactivate_device(self) -> tuple[bool, str]:
"""
Check if device can be deactivated (cooldown check).
Returns:
(allowed, reason) tuple
"""
try:
tier_limit = self.tier.device_limit
except DeviceLimit.DoesNotExist:
return False, "Device limits not configured for tier"
if not tier_limit.allow_deactivation:
return False, "Device deactivation not allowed for this tier"
if not self.last_deactivation_at:
return True, "No previous deactivations"
cooldown = timedelta(days=tier_limit.deactivation_cooldown_days)
time_since_last = timezone.now() - self.last_deactivation_at
if time_since_last < cooldown:
days_remaining = (cooldown - time_since_last).days
return False, f"Cooldown period: {days_remaining} days remaining"
return True, "Cooldown elapsed"
2. Device Activation Endpoint
File: backend/devices/views.py
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from django.utils import timezone
from licenses.models import License, DeviceActivation, DeviceDeactivationLog
@api_view(['POST'])
def activate_device(request):
"""
Activate device for license.
Request Body:
{
"license_key": "LIC-...",
"hardware_id": "sha256 hash",
"device_name": "MacBook Pro",
"os_name": "macOS",
"os_version": "14.0",
"hostname": "johns-mbp.local"
}
Response (Success):
{
"activated": true,
"device_id": "uuid",
"devices_used": 3,
"devices_limit": 3,
"warning": "Last device slot used"
}
Response (Limit Exceeded):
{
"activated": false,
"error": "device_limit_exceeded",
"devices_used": 3,
"devices_limit": 3,
"message": "Device limit reached. Deactivate unused devices.",
"manage_devices_url": "https://coditect.ai/devices"
}
Response (Already Activated):
{
"activated": true,
"device_id": "uuid",
"message": "Device already activated",
"last_seen": "2025-11-29T12:00:00Z"
}
"""
license_key = request.data.get('license_key')
hardware_id = request.data.get('hardware_id')
device_name = request.data.get('device_name', 'Unknown Device')
os_name = request.data.get('os_name', 'Unknown')
os_version = request.data.get('os_version', '')
hostname = request.data.get('hostname', '')
# Validate required fields
if not license_key or not hardware_id:
return Response(
{'error': 'license_key and hardware_id required'},
status=status.HTTP_400_BAD_REQUEST
)
# Get license
try:
license_obj = License.objects.select_related('tier').get(
license_key=license_key
)
except License.DoesNotExist:
return Response(
{'error': 'Invalid license key'},
status=status.HTTP_404_NOT_FOUND
)
# Check if license is active
if license_obj.status != License.Status.ACTIVE:
return Response({
'activated': False,
'error': 'license_inactive',
'message': f"License is {license_obj.status}"
}, status=status.HTTP_403_FORBIDDEN)
# Check if device already activated
existing_device = DeviceActivation.objects.filter(
license=license_obj,
hardware_id=hardware_id
).first()
if existing_device and existing_device.status == DeviceActivation.Status.ACTIVE:
# Reactivation - update last_seen
existing_device.last_seen_at = timezone.now()
existing_device.save()
return Response({
'activated': True,
'device_id': str(existing_device.id),
'message': 'Device already activated',
'last_seen': existing_device.last_seen_at
}, status=status.HTTP_200_OK)
# Check device limit
if not license_obj.can_activate_new_device:
return Response({
'activated': False,
'error': 'device_limit_exceeded',
'devices_used': license_obj.active_device_count,
'devices_limit': license_obj.device_limit,
'message': f"Device limit reached ({license_obj.device_limit}). Deactivate unused devices to continue.",
'manage_devices_url': f"{settings.FRONTEND_URL}/devices"
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
# Create new activation
device = DeviceActivation.objects.create(
license=license_obj,
tenant=license_obj.tenant,
hardware_id=hardware_id,
device_name=device_name,
os_name=os_name,
os_version=os_version,
hostname=hostname,
status=DeviceActivation.Status.ACTIVE
)
# Check if last slot used
devices_used = license_obj.active_device_count
warning = None
if devices_used == license_obj.device_limit:
warning = f"Last device slot used ({devices_used}/{license_obj.device_limit})"
return Response({
'activated': True,
'device_id': str(device.id),
'devices_used': devices_used,
'devices_limit': license_obj.device_limit,
'warning': warning
}, status=status.HTTP_201_CREATED)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def list_devices(request):
"""
List all device activations for license.
Query Params:
?license_key=LIC-...
Response:
{
"devices": [
{
"device_id": "uuid",
"device_name": "MacBook Pro",
"hardware_id": "a1b2c3d4...",
"os": "macOS 14.0",
"activated_at": "2025-11-01T10:00:00Z",
"last_seen_at": "2025-11-29T12:00:00Z",
"status": "active"
}
],
"devices_used": 2,
"devices_limit": 3
}
"""
license_key = request.GET.get('license_key')
try:
license_obj = License.objects.get(license_key=license_key)
except License.DoesNotExist:
return Response({'error': 'Invalid license key'}, status=status.HTTP_404_NOT_FOUND)
devices = DeviceActivation.objects.filter(
license=license_obj,
status=DeviceActivation.Status.ACTIVE
).values(
'id', 'device_name', 'hardware_id', 'os_name', 'os_version',
'activated_at', 'last_seen_at', 'status'
)
return Response({
'devices': list(devices),
'devices_used': license_obj.active_device_count,
'devices_limit': license_obj.device_limit
})
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
def deactivate_device(request, device_id):
"""
Deactivate device.
Path Params:
device_id: Device activation ID
Response (Success):
{
"deactivated": true,
"message": "Device deactivated successfully"
}
Response (Cooldown):
{
"deactivated": false,
"error": "cooldown",
"days_remaining": 5,
"message": "Can deactivate in 5 days"
}
Response (Not Allowed):
{
"deactivated": false,
"error": "not_allowed",
"message": "Device deactivation not allowed for Free tier"
}
"""
try:
device = DeviceActivation.objects.select_related('license').get(
id=device_id,
status=DeviceActivation.Status.ACTIVE
)
except DeviceActivation.DoesNotExist:
return Response(
{'error': 'Device not found or already deactivated'},
status=status.HTTP_404_NOT_FOUND
)
license_obj = device.license
# Check if deactivation allowed
can_deactivate, reason = license_obj.can_deactivate_device()
if not can_deactivate:
# Extract days remaining from reason string if cooldown
if "days remaining" in reason:
import re
days_match = re.search(r'(\d+) days', reason)
days_remaining = int(days_match.group(1)) if days_match else 0
return Response({
'deactivated': False,
'error': 'cooldown',
'days_remaining': days_remaining,
'message': reason
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
else:
return Response({
'deactivated': False,
'error': 'not_allowed',
'message': reason
}, status=status.HTTP_403_FORBIDDEN)
# Deactivate device
device.status = DeviceActivation.Status.DEACTIVATED
device.deactivated_at = timezone.now()
device.deactivation_reason = 'user_requested'
device.save()
# Update license last_deactivation_at
license_obj.last_deactivation_at = timezone.now()
license_obj.save()
# Log deactivation
DeviceDeactivationLog.objects.create(
device=device,
license=license_obj,
tenant=license_obj.tenant,
reason='user_requested',
initiated_by='user'
)
return Response({
'deactivated': True,
'message': 'Device deactivated successfully',
'devices_remaining': license_obj.active_device_count
}, status=status.HTTP_200_OK)
3. Hardware ID Generation (Client-Side)
File: .coditect/scripts/generate-hardware-id.py
#!/usr/bin/env python3
"""
Generate stable hardware ID for device activation.
Hardware ID is SHA256 hash of:
- CPU model
- Primary disk serial number
- Primary network interface MAC address
"""
import hashlib
import platform
import subprocess
import sys
def get_cpu_id() -> str:
"""Get CPU identifier."""
system = platform.system()
if system == 'Darwin': # macOS
result = subprocess.run(
['sysctl', '-n', 'machdep.cpu.brand_string'],
capture_output=True,
text=True
)
return result.stdout.strip()
elif system == 'Linux':
with open('/proc/cpuinfo', 'r') as f:
for line in f:
if 'model name' in line:
return line.split(':')[1].strip()
elif system == 'Windows':
result = subprocess.run(
['wmic', 'cpu', 'get', 'name'],
capture_output=True,
text=True
)
lines = result.stdout.strip().split('\n')
return lines[1].strip() if len(lines) > 1 else ''
return 'unknown'
def get_disk_serial() -> str:
"""Get primary disk serial number."""
system = platform.system()
if system == 'Darwin':
result = subprocess.run(
['system_profiler', 'SPStorageDataType'],
capture_output=True,
text=True
)
for line in result.stdout.split('\n'):
if 'Serial Number' in line:
return line.split(':')[1].strip()
elif system == 'Linux':
result = subprocess.run(
['lsblk', '-o', 'SERIAL', '-n'],
capture_output=True,
text=True
)
serials = result.stdout.strip().split('\n')
return serials[0] if serials else ''
elif system == 'Windows':
result = subprocess.run(
['wmic', 'diskdrive', 'get', 'serialnumber'],
capture_output=True,
text=True
)
lines = result.stdout.strip().split('\n')
return lines[1].strip() if len(lines) > 1 else ''
return 'unknown'
def get_mac_address() -> str:
"""Get primary network interface MAC address."""
import uuid
mac = uuid.getnode()
mac_str = ':'.join(f'{(mac >> i) & 0xff:02x}' for i in range(0, 48, 8))
return mac_str
def generate_hardware_id() -> str:
"""
Generate stable hardware ID.
Combines:
- CPU model
- Disk serial
- MAC address
Returns SHA256 hash for privacy.
"""
cpu = get_cpu_id()
disk = get_disk_serial()
mac = get_mac_address()
# Combine hardware attributes
hardware_string = f"{cpu}|{disk}|{mac}"
# SHA256 hash for privacy
hardware_id = hashlib.sha256(hardware_string.encode()).hexdigest()
return hardware_id
if __name__ == '__main__':
hardware_id = generate_hardware_id()
print(hardware_id)
Consequences
Positive
✅ Revenue Protection
- Prevent license sharing abuse
- 1 Pro license = 1 developer (3 devices max)
- Estimated 20% revenue recovery from prevented abuse
✅ Fair Usage Policy
- Legitimate users get 3 devices (work + home + backup)
- Clear limits communicated upfront
- Self-service device management
✅ Security Enhancement
- Stolen license keys limited to existing devices
- Hardware ID changes if machine cloned
- Audit trail of device activations
✅ Support Reduction
- Self-service deactivation reduces tickets
- Clear error messages guide users
- Device management dashboard empowers users
Negative
⚠️ User Friction
- Hardware changes require reactivation
- 30-day cooldown may frustrate users
- Support tickets: "My new SSD locked me out"
⚠️ Privacy Concerns
- Hardware IDs stored on server
- Mitigation: SHA256 hash (irreversible)
- Transparency: Privacy policy disclosure
⚠️ CI/CD Complexity
- Build agents shouldn't count as separate devices
- Mitigation: CI/CD exemption via environment variable
- Detection: Ephemeral environment patterns
Related ADRs
- ADR-001: Floating vs Node-Locked Licenses
- ADR-010: Feature Gating Matrix (tier-based device limits)
References
Last Updated: 2025-11-30 Owner: Product Team, Security Team Review Cycle: Quarterly