Skip to main content

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:

  1. Hardware Fingerprinting (CPU ID + Disk Serial + MAC Address → SHA256)
  2. Tier-Based Device Limits (Free: 1, Pro: 3, Team: unlimited)
  3. Self-Service Device Management (deactivation dashboard, 30-day cooldown)
  4. CI/CD Detection (exempt ephemeral environments from device limits)
  5. 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

  • 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