Houdini HDA Integration¶
Protect your Houdini Digital Assets with per-machine license checking baked directly into the HDA. Users activate on first load, and the license state persists across sessions via a cached JWT token.
Overview¶
The HDA is self-contained. The license client lives inside the asset alongside your tool logic. End users receive a single .hdalc file and a Houdini package JSON.
Why self-contained? Keeps distribution simple. No separate installer, no separate Python package install required. Everything the license system needs travels with the asset.
Architecture Flow¶
HDA OnCreated / OnLoaded
|
v
[ cg_license client ]
|
+---> Cached JWT exists?
| |
| YES --> verify signature locally
| | |
| | valid --> load tool normally
| | |
| | expired --> grace period check
| | |
| | within grace --> load with warning dialog
| | |
| | past grace --> block, show reactivation dialog
| |
| NO
| |
+---> Online? --> YES --> show activation dialog
|
NO --> block (no cached token, no network)
Distribution Structure¶
your_tool_package/
├── otls/
│ └── your_tool.hdalc # compiled/locked HDA
├── vendor/
│ └── cg_license/
│ ├── __init__.py
│ ├── license.py
│ ├── api.py
│ ├── exceptions.py
│ ├── fingerprint.py
│ └── storage.py
└── packages/
└── your_tool.json # Houdini package file
Houdini Package File¶
{
"path": "$HOUDINI_PACKAGE_PATH",
"load_package_once": true,
"env": [
{
"YOUR_TOOL_ROOT": "$HOUDINI_PACKAGE_DIR/.."
}
],
"hpath": "$YOUR_TOOL_ROOT/otls",
"path": "$YOUR_TOOL_ROOT/vendor"
}
Place this file in one of:
~/houdini20.5/packages/your_tool.json(user install)$HFS/packages/your_tool.json(system install)
HDA Configuration¶
Open the HDA in the Type Properties editor and set up these event handlers under the Scripts tab:
| Event | Handler function |
|---|---|
OnCreated |
hou_event_callbacks.onCreated(kwargs) |
OnLoaded |
hou_event_callbacks.onLoaded(kwargs) |
OnDeleted |
hou_event_callbacks.onDeleted(kwargs) |
All logic lives in the PythonModule section.
PythonModule Code¶
"""
PythonModule for your_tool HDA.
Handles license checking, activation, and deactivation.
"""
import sys
import os
import hou
# ---------------------------------------------------------------------------
# Config (set these at HDA build time)
# ---------------------------------------------------------------------------
PRODUCT_ID = "prod_abc123"
PRODUCT_SALT = "your-product-salt"
SERVER_URL = "https://your-domain.com"
# ---------------------------------------------------------------------------
# Vendor path setup
# The vendor folder ships alongside the otls/ dir.
# ---------------------------------------------------------------------------
def _get_vendor_path():
# Try package env var first
root = os.environ.get("YOUR_TOOL_ROOT")
if root:
return os.path.join(root, "vendor")
# Fallback: locate via HDA definition path
defn = hou.nodeType(hou.nodeTypeCategories()["Sop"], "your_tool")
if defn:
hda_path = defn.definition().libraryFilePath()
return os.path.normpath(os.path.join(os.path.dirname(hda_path), "..", "vendor"))
return None
_vendor = _get_vendor_path()
if _vendor and _vendor not in sys.path:
sys.path.insert(0, _vendor)
try:
from cg_license import LicenseClient
from cg_license.exceptions import (
LicenseNotFoundError,
LicenseRevokedError,
LicenseExpiredError,
MachineLimitError,
NetworkError,
InvalidTokenError,
)
_sdk_available = True
except ImportError as e:
_sdk_available = False
_sdk_import_error = str(e)
# ---------------------------------------------------------------------------
# License client singleton
# ---------------------------------------------------------------------------
_client = None
def _get_client():
global _client
if _client is None:
_client = LicenseClient(
server_url=SERVER_URL,
product_id=PRODUCT_ID,
product_salt=PRODUCT_SALT,
)
return _client
# ---------------------------------------------------------------------------
# Core license functions
# ---------------------------------------------------------------------------
def check_license(node):
"""
Returns True if the license is valid (online or cached).
Raises on hard failures. Call this from OnCreated/OnLoaded.
"""
if not _sdk_available:
hou.ui.displayMessage(
f"License SDK failed to load:\n{_sdk_import_error}\n\n"
"Ensure the vendor/ folder is present alongside the HDA.",
title="License Error",
severity=hou.severityType.Error,
)
return False
client = _get_client()
if client.check_license():
# check_license() returns True even for degraded status
error = client.get_error()
if error and error.code == "degraded":
hou.ui.displayMessage(
error.message,
title="License Warning",
severity=hou.severityType.Warning,
)
return True
# License check failed, inspect the error
error = client.get_error()
if error is None:
return _show_activation_dialog(node)
if error.code == "not_activated" or error.code == "invalid_token":
return _show_activation_dialog(node)
elif error.code == "revoked":
hou.ui.displayMessage(
"Your license has been revoked.\nPlease contact support.",
title="License Revoked",
severity=hou.severityType.Error,
)
elif error.code == "expired":
hou.ui.displayMessage(
"Your license has expired.\nPlease renew at cglounge.com",
title="License Expired",
severity=hou.severityType.Error,
)
elif error.code == "suspended":
hou.ui.displayMessage(
error.message or "License suspended. Contact support.",
title="License Suspended",
severity=hou.severityType.Error,
)
elif error.code == "network":
hou.ui.displayMessage(
"Cannot reach the license server and no cached license found.\n"
"Please connect to the internet to activate.",
title="Network Error",
severity=hou.severityType.Error,
)
else:
hou.ui.displayMessage(
f"License error: {error.message}",
title="License Error",
severity=hou.severityType.Error,
)
return False
def _show_activation_dialog(node):
"""Prompt for license key and attempt activation."""
key = hou.ui.readInput(
"Enter your license key from cglounge.com/library:",
title="Activate License",
)
if key is None or not key[1].strip():
return False
return activate(node, key[1].strip())
def activate(node, key: str) -> bool:
"""Activate a license key on this machine."""
client = _get_client()
try:
info = client.activate(key)
hou.ui.displayMessage(
f"License activated!\n"
f"Machines: {info.get('machinesUsed')}/{info.get('maxMachines')}",
title="Activated",
)
return True
except LicenseNotFoundError:
hou.ui.displayMessage("Invalid license key.", title="Activation Failed",
severity=hou.severityType.Error)
return False
except MachineLimitError as e:
hou.ui.displayMessage(
f"Machine limit reached.\n"
"Deactivate a machine at cglounge.com/library first.",
title="Activation Failed",
severity=hou.severityType.Error,
)
return False
def deactivate(node):
"""Deactivate this machine and clear cached token."""
client = _get_client()
try:
client.deactivate()
hou.ui.displayMessage("License deactivated on this machine.", title="Deactivated")
except NetworkError:
hou.ui.displayMessage(
"Cannot reach server. Deactivation requires an internet connection.",
title="Deactivation Failed",
severity=hou.severityType.Warning,
)
def require_license(node):
"""
Hard gate: delete the node if license is invalid.
Use this if you want zero tolerance (no grace period loading).
"""
if not check_license(node):
hou.ui.displayMessage(
"Node will be deleted: no valid license.",
title="License Required",
severity=hou.severityType.Error,
)
node.destroy()
# ---------------------------------------------------------------------------
# HDA event callbacks (wire these in the Scripts tab)
# ---------------------------------------------------------------------------
def onCreated(kwargs):
node = kwargs["node"]
check_license(node)
def onLoaded(kwargs):
node = kwargs["node"]
check_license(node)
def onDeleted(kwargs):
# Optional: deactivate on node deletion.
# Only do this if your license model expects it.
pass
# ---------------------------------------------------------------------------
# Optional: expose deactivation via node menu button
# Add a Button parameter named "deactivate_license" and call:
# hou_event_callbacks.on_deactivate_pressed(kwargs)
# ---------------------------------------------------------------------------
def on_deactivate_pressed(kwargs):
node = kwargs["node"]
deactivate(node)
Black Box Locking¶
Black-boxing compiles your HDA's node network so users cannot inspect or extract the internal logic.
How to compile:
- Right-click the HDA in the network editor.
- Select Type Properties.
- Under Options, enable Save Operator Type as Black Box.
- Save and accept.
Alternatively, via hscript:
Protection Levels¶
| Level | What it does | How to apply |
|---|---|---|
| Black box | Hides internal node network | Type Properties > Options |
.hdalc format |
Compiled binary, not plain text | Save as .hdalc (not .hda) |
| Code obfuscation | Makes PythonModule harder to read | Use a Python obfuscator at build time |
| Signed distribution | Detects file tampering | Sign with your private key (custom pipeline) |
Security Considerations¶
What is protected¶
| Attack | Protected? | Notes |
|---|---|---|
| Casual inspection of node network | Yes | Black box hides it |
| Extraction of PythonModule code | Partial | .hdalc is binary but not encrypted |
| Bypassing the license check by editing the HDA | No | Determined attacker can unpack and patch |
| Using the tool offline after grace period | Yes | JWT expires, server re-validation required |
| Sharing a license key between machines | Yes | Machine limit enforced server-side |
| Sharing a cached JWT between machines | Yes | JWT is bound to machine fingerprint |
| Cracking the PRODUCT_SALT from the binary | Possible with effort | Salt is embedded; use build-time injection |
Realistic threat model¶
Most users are not reverse engineers. The goal is:
- Make casual copying non-trivial (black box + compiled HDA).
- Make license sharing impossible (server-side machine limits).
- Make offline abuse time-limited (JWT expiry + grace period).
- Accept that a determined attacker with enough time can bypass code-level protection.
The license server enforces the hard rules. The HDA code is a UX layer, not a cryptographic vault.
User Workflow¶
First-time activation¶
- User drops the HDA into their scene.
OnCreatedfires,check_license()runs.- No cached token found: activation dialog appears.
- User pastes their key from cglounge.com/library.
- Server validates, JWT cached to disk, tool loads.
Subsequent uses (same machine)¶
OnLoadedfires.- Cached JWT found and valid: silent pass, tool loads instantly.
Offline use¶
OnLoadedfires.- Cached JWT found, signature valid, not yet expired: silent pass.
- If JWT expired but within 7-day grace: loads with a warning dialog.
- If past grace: warning dialog, loads anyway (you can change to hard-block via
require_license).
Machine limit reached¶
- User tries to activate on a new machine.
- Server rejects:
MachineLimitError. - Dialog explains the limit and links to cglounge.com/library to deactivate an old machine.
Distribution Checklist¶
- [ ]
PRODUCT_ID,PRODUCT_SALT,SERVER_URLset to production values - [ ] HDA saved as
.hdalc(binary format) - [ ] Black box enabled in Type Properties
- [ ]
vendor/cg_license/folder included in the distribution zip - [ ]
packages/your_tool.jsonincluded and tested in a clean Houdini install - [ ]
onCreatedandonLoadedwired to the correct callback functions - [ ] Tested activation flow on a machine with no cached token
- [ ] Tested offline flow (disable network, verify grace period behavior)
- [ ] Deactivation accessible (Button parameter or Help menu shelf tool)
- [ ] Support email visible in all error dialogs