Instance Verification and Licensing
Date: 2026-05-20 Status: Accepted
Decision
The mobile app supports connecting to any Range Day Pro server via a user-configurable server URL. Connections are verified using a cryptographic license system: T.REX ARMS signs a license per deployment with a private Ed25519 key, the app verifies it with an embedded public key, and no authentication or user data is sent until verification passes. This serves three purposes simultaneously: phishing protection, white-label licensing/DRM, and developer/staging environment switching.
Context
Range Day Pro has multiple deployment contexts:
- T.REX ARMS SaaS — the default, at
rangedaypro.com - White-label deployments — military, LE, private organizations running their own server on private or air-gapped networks
- Staging/development — internal environments for testing before production releases
All of these need to be reachable from the same App Store binary. The app can’t be rebuilt per customer. White-label customers need a way to point the stock app at their server. Developers need to switch between staging and production. And in all cases, the app must not send credentials to an unverified server.
License Issuance
T.REX ARMS holds an Ed25519 private signing key. This key never leaves T.REX infrastructure. The corresponding public key is embedded in the app binary (compiled into the Rust core crate).
When onboarding a white-label customer, T.REX generates a license file:
{
"customer": "Springfield PD",
"domains": ["training.springfieldpd.gov"],
"issued": "2026-06-01",
"expires": "2027-06-01",
"capabilities": {
"max_users": 500,
"public_signup": false,
"billing": false,
"drill_catalog": false
}
}The claims are signed with the private key, producing a license file. The customer places this file alongside their config.yaml on the server. The T.REX SaaS instance has its own license (signed for rangedaypro.com) — no special cases in the app.
A small CLI tool or rangeday license issue command handles generation. Ed25519 signing is straightforward with ed25519-dalek.
Handshake Protocol
When a user enters a server URL, the app performs a verification handshake before showing any login UI:
User enters URL
|
v
App calls GetInstanceInfo (unauthenticated, no user data)
|
v
Server returns InstanceInfo:
- instance_name, org_name
- branding (colors, logo_url)
- capability flags
- license claims (customer, domains, expires, capabilities)
- license signature (Ed25519 bytes)
|
v
App verifies (all must pass):
1. Signature valid (using embedded public key)
2. URL user entered matches a domain in the license claims
3. License not expired
|
pass | fail
v v
Show login "Not a verified Range Day Pro
with custom server" — no auth UI shown
branding
No user credentials, tokens, or identifying information are sent before verification passes. The handshake is a single unauthenticated RPC.
Proto Definition
New service in instance.v1:
service InstanceService {
rpc GetInstanceInfo(GetInstanceInfoRequest) returns (GetInstanceInfoResponse);
}
message GetInstanceInfoRequest {}
message GetInstanceInfoResponse {
InstanceInfo instance = 1;
}
message InstanceInfo {
string instance_name = 1; // "Range Day Pro" or "Springfield PD Training"
string org_name = 2; // "T.REX ARMS" or "Springfield Police Department"
BrandingConfig branding = 3;
CapabilityFlags capabilities = 4;
string api_version = 5; // proto compatibility check
bytes license_claims = 6; // raw JSON bytes of the license claims
bytes license_signature = 7; // Ed25519 signature over license_claims
}
message BrandingConfig {
string primary_color = 1;
string accent_color = 2;
string logo_url = 3; // relative to server base URL
string favicon_url = 4;
}
message CapabilityFlags {
bool public_signup = 1;
bool billing_enabled = 2;
bool drill_catalog = 3;
}The server reads its config.yaml and license file at startup and serves the response from memory. The license_claims field is the raw signed bytes (not re-serialized), ensuring the signature verification is exact.
App UI
The server URL selector is minimal — visible but not prominent. It sits at the bottom of the login screen as small, muted text:
+-----------------------------+
| |
| [ T.REX ARMS LOGO ] |
| Range Day Pro |
| |
| Email: [ ] |
| Password: [ ] |
| |
| [ Sign In ] |
| [ Register ] |
| |
| |
| rangedaypro.com - Change |
| |
+-----------------------------+
Behavior:
- Default state:
rangedaypro.com— 95% of users never touch this. - “Change” tap: Opens a sheet/modal with a URL input field. User types a URL, app runs the handshake.
- On success: Login screen re-renders with the server’s branding (name, colors, logo fetched from
logo_url). The URL persists in local storage. - On failure: Error message in the modal (“Not a verified Range Day Pro server” or “License expired”). No navigation to login.
- Switching servers: Clears local auth tokens (old tokens are meaningless for a different server). If unsynced scores exist in the offline queue, prompts the user before clearing.
For white-label IT admins, the instruction is: “Download Range Day Pro from the App Store. Tap ‘Change’ at the bottom of the login screen. Enter training.springfieldpd.gov.”
The capability flags from the handshake also control what UI is shown. If public_signup is false, the “Register” button is hidden. If billing_enabled is false, no payment UI anywhere in the app.
Security Properties
| Threat | Mitigation |
|---|---|
| Phishing server harvests credentials | App verifies Ed25519 signature before showing login. Attacker cannot forge a signature without T.REX’s private key. |
| Attacker stands up fake server | Without a valid license signed by T.REX, the app refuses to connect. |
| License sharing between customers | Domain binding — the license is only valid for the domains listed in its claims. |
| Expired white-label customer | License has an expiration date. App and server both reject expired licenses. |
| Man-in-the-middle | TLS required (app rejects HTTP URLs). Combined with license verification, both transport and application layers are secured. |
| Reverse-engineering the app binary | Only the public key is embedded. Attacker can verify signatures but not forge them. |
| Binary patching to skip verification | Non-trivial, breaks on every app update, and is a license violation. Proportionate deterrent for the threat model. |
DRM Properties
The license system doubles as white-label DRM:
- T.REX controls distribution. Only servers with a T.REX-issued license are accepted by the app. Running the server binary without a valid license means no mobile app can connect.
- Domain-bound. A license for
training.springfieldpd.govcannot be reused ontraining.otherpd.gov. - Time-bound. Expiration dates enable subscription-based white-label licensing. Customer renews, receives a new license file, drops it on their server.
- Capability-bound. The license claims can restrict features (max users, billing, drill catalog). The server enforces these server-side; the app uses them for UI adaptation.
- Offline-compatible. Verification is purely cryptographic — no phone-home to T.REX servers. Works on air-gapped military networks.
All Clients Protected
The verification logic lives in libs/core, which all four client apps import:
| Client | Import method | Notes |
|---|---|---|
| iOS | UniFFI (Swift binding) | Native binary, same as any compiled app |
| Android | UniFFI (Kotlin binding) | Native binary, same as any compiled app |
| PWA | Direct crate import (Rust → WASM) | WASM binary format, not human-readable source |
| Dashboard | Direct crate import (Rust → WASM) | WASM binary format, not human-readable source |
All four clients run the same handshake before showing any auth UI. No platform-specific verification code.
The embedded public key is discoverable by a determined reverse engineer on any platform (native disassembly, WASM decompilation). This is fine — the public key only allows verification of signatures, not forgery. The security model assumes all client binaries are public. The private signing key never leaves T.REX infrastructure.
Implementation
Verification lives in core
Crate: ed25519-dalek (pure Rust, no-std compatible, works on WASM/iOS/Android).
Server serves the response
The server loads config.yaml + license file at startup. GetInstanceInfo is an unauthenticated ConnectRPC handler that returns the pre-loaded response. The license file bytes are served as-is (not re-serialized) to preserve the exact signed payload.
The server may also validate its own license at startup and refuse to start if expired, providing a server-side enforcement layer independent of the app.
License generation tool
A CLI command (rangeday license issue) or standalone script that takes customer details and the private key, produces a signed license file. Used during white-label customer onboarding.
Consequences
- A new
instance.v1proto service is needed - The Rust core crate gains a dependency on
ed25519-dalek - T.REX ARMS must securely store and manage the Ed25519 private signing key
- White-label customer onboarding includes a license generation step
- The app login screen gains a small server URL selector
- Server URL and verified instance info are persisted in local storage on all clients
- The T.REX SaaS instance needs its own license file (no special-casing)