Pairing Protocol Specification β
Version: 1.0
Status: Design (Phase C.1)
Audience: Developers implementing Phase C (serverβhub pairing)
Overview β
The pairing protocol establishes a trust relationship between a self-hosted phlix-server instance and a phlix-hub instance. Once paired, the hub can:
- Broker authentication so clients can access the server from anywhere
- Provide relay connectivity when direct LAN access is unavailable
- Offer a unified "my servers" dashboard for users with multiple homes
Protocol Flow (Summary) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PAIRING FLOW β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β 1. Server starts β
β β generates Ed25519 keypair β
β β stores JWKS at /.well-known/jwks.json (self-hosted) β
β β sends POST /api/v1/server-claims/new to hub β
β β
β 2. Hub responds β
β β { claim_code: "ABCD-1234", expires_in: 600, claim_id } β
β β
β 3. Server displays claim_code on screen/CLI β
β β
β 4. User logs into hub web portal β
β β POST /api/v1/server-claims/claim with { claim_code } β
β β
β 5. Hub atomically: validates code + associates server with user β
β β returns { enrollment_jwt, hub_jwks_url } β
β β
β 6. Server stores enrollment_jwt + hub_jwks_url β
β β starts heartbeat loop (POST /api/v1/servers/{id}/heartbeat) β
β β
β 7. Server continues publishing JWKS at /.well-known/jwks.json β
β β
β 8. Hub issues user-session JWTs with user_id + server_id audience β
β β
β 9. Client receives JWT from hub, presents to server β
β β Server validates against hub's JWKS URL β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ1. Server Keypair β
Algorithm Selection β
Ed25519 (EdDSA) is used for the server's signing keypair.
Rationale:
- Modern and secure β not susceptible to RSA's many implementation pitfalls, no need for large key sizes
- Small keys β 32-byte public key fits comfortably in JSON
- Small signatures β 64 bytes, lower overhead than RSA/ECDSA
- Fast signing β less CPU overhead on low-power NAS devices
- RFC 8032 compliant β widely supported, including
sodium_crypto_sign_*
Rejected alternatives:
- RSA 2048 β larger keys (256 bytes), larger signatures (256 bytes), slower to sign, more attack surface
- ECDSA P-256 β smaller than RSA but has several implementation pitfalls (curve non-monotonicity, timing leaks); Ed25519 is cleaner
- X25519 β key exchange only, not signing; wrong tool
Key Storage β
Server stores its Ed25519 private key in:
config/hub-server-key.pem # raw PEM-encoded Ed25519 private keyThe corresponding public key is extracted and embedded in the JWKS document (see Β§2).
The private key file must have 0600 permissions. If it does not exist on first boot, the server generates one automatically:
$keypair = sodium_crypto_sign_keypair(); // 96 bytes: 64-byte secret + 32-byte pub
$secretKey = substr($keypair, 0, 64); // first 64 bytes = secret
$publicKey = substr($keypair, 64); // last 32 bytes = public
// Store PEM β note base64URL, not plain base64
$b64 = rtrim(strtr(base64_encode($secretKey), '+/', '-_'), '=');
file_put_contents($keyPath, "-----BEGIN ED25519 PRIVATE KEY-----\n"
. $b64 . "\n-----END ED25519 PRIVATE KEY-----\n");
chmod($keyPath, 0600);β sodium_crypto_sign_keypair() returns 96 bytes, not 64. Splitting at byte 32 (as earlier revisions of this page did) produces a truncated, unusable key.
Key Rotation β
Key rotation is not wired up
Phlix\Hub\Ed25519KeyManager::rotate() exists and generates and persists a fresh keypair, but nothing calls it outside unit tests β there is no scripts/rotate-hub-key.php (no such file exists), no bin/phlix subcommand and no admin endpoint. Server-side rotation also has no overlap window: rotate() overwrites config/hub-server-key.pem in place, so the outgoing public key becomes immediately unavailable and any JWT still bearing the old kid fails verification.
The hub-side manager (phlix-hub/src/Hub/Ed25519KeyManager.php) does retain the outgoing public key for a 24-hour overlap, but its rotate() is likewise uncalled in production. Treat everything below as the intended design, not current behaviour.
The intended design is:
- Keys are rotated when the server operator explicitly triggers it
- On rotation, a new keypair is generated, the new JWKS is published, and heartbeats carry both the new key ID and the old key ID (for a 24-hour overlap window where both old and new signatures are accepted)
- After 24 hours, only the new key is accepted
- The old private key is deleted after the overlap window
2. JWKS β Server's Own Keys β
URL β
The server self-hosts its JWKS. This is the canonical and preferred approach β it avoids the hub having to store and proxy keys.
https://<server-hostname>:8096/.well-known/jwks.json
https://<server-hostname>:8096/.well-known/jwks.json?kty=OKP&alg=Ed25519The path /.well-known/jwks.json is always relative to the server's root, not the web portal root. It is served by the Workerman HTTP server directly (not Smarty).
Document Format β
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "11qYjhK5HRVDum2bHqDQD0gRNYVWg0Wmg2TTKJSbZ-g",
"kid": "2026-05-17T00:00:00Z",
"use": "sig",
"alg": "EdDSA"
}
]
}kty: "OKP"β Octet Key Pair (Ed25519/Ed448)crv: "Ed25519"β curve identifierxβ base64url-encoded 32-byte public keykidβ key ID: the base64url of the first 8 bytes of SHA-256 over the public key. It is a deterministic fingerprint, not a timestamp, and changes on rotation. (An ISO 8601kidis generated only in the fallback case where no key file exists at all.)use: "sig"β signature keyalg: "EdDSA"β algorithm identifier
Serving the JWKS β
The Workerman HTTP server handles this directly:
$router->get('/.well-known/jwks.json', function ($request) {
$keys = $this->hubClient->getPublicKeysJwk();
return (new Response())
->status(200)
->header('Content-Type', 'application/json')
->header('Cache-Control', 'public, max-age=3600')
->json(['keys' => $keys]);
});Cache-Control allows CDN edge-caching of the public document without sensitive material.
3. Claim Code β
Generation β
Claim codes are 8-character alphanumeric (two groups of four), uppercase letters + digits (excluding 0, O, I, 1 to avoid ambiguity):
Pattern: [A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}
Example: "ABCD-2345"- Entropy: 32^4 Γ 32^4 = 2^40 β 1 trillion possibilities
- Generated by the hub using a cryptographically secure RNG
- Stored in the hub DB with a 10-minute TTL. The TTL is a bare literal in the handler β there is no setting and no environment variable for it.
- Single-use: the claim is taken under
SELECT β¦ FOR UPDATEin an explicit transaction and markedclaimed_by; a second attempt getsCLAIM_CODE_ALREADY_CLAIMED. The row itself is deleted later, once the server retrieves its enrollment JWT viaGET /api/v1/server-claims/{claimId}β not at claim time.
Generation Algorithm β
function generateClaimCode(): string {
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0, O, I, 1
$code = '';
for ($i = 0; $i < 4; $i++) {
$code .= $chars[random_int(0, 31)];
}
$code .= '-';
for ($i = 0; $i < 4; $i++) {
$code .= $chars[random_int(0, 31)];
}
return $code;
}4. Server β Hub: Claim Request β
Endpoint β
POST https://hub.example.com/api/v1/server-claims/newRequest Headers β
Accept-Phlix-Protocol: v1
Content-Type: application/jsonRequest Body β
{
"server_name": "Alice's NAS",
"version": "0.11.0",
"public_keys": {
"kty": "OKP",
"crv": "Ed25519",
"x": "11qYjhK5HRVDum2bHqDQD0gRNYVWg0Wmg2TTKJSbZ-g",
"kid": "2026-05-17T00:00:00Z",
"use": "sig",
"alg": "EdDSA"
},
"hostname_candidates": [
"https://192.168.1.100:32400",
"https://alice-nas.local:32400"
],
"protocol_version": "v1"
}Field Descriptions β
| Field | Type | Required | Description |
|---|---|---|---|
server_name | string | Yes | Operator-chosen friendly name shown on hub dashboard |
version | string | Yes | Server semver (e.g., 0.11.0). Hub may reject incompatible versions |
public_keys | object | Yes | JWK of the server's Ed25519 public key (kid references current active key) |
hostname_candidates | list<string> | Yes | Hostnames/IPs the server believes it is reachable at. Hub uses the first publicly reachable one; falls back to relay |
protocol_version | string | Yes | Fixed at "v1". Hub validates this header value |
Why
:32400in the candidates but:8096for JWKS. The LAN, mDNS and public candidates are built byPortForwardService::discoverHostnameCandidates()from the port-forwarding port (PHLIX_EXTERNAL_PORT, default32400), not from the port the HTTP server binds (config/server.phpserver.port, default8096). With both defaults left alone the advertised candidates point at a port nothing is listening on β setPHLIX_EXTERNAL_PORTto matchserver.port. See Environment variables.
Hub Validation on Claim Request β
Before accepting a claim request, the hub:
- Validates
protocol_versionβ must be"v1"; rejects withHUB_PROTOCOL_UNSUPPORTEDif not - Checks
versionagainsthub_min_server_versionβ rejects if server version is too old withSERVER_VERSION_INCOMPATIBLE - Validates the JWK structure β must be a well-formed Ed25519 public key; rejects with
SERVER_KEY_INVALIDif malformed - Checks for duplicate
server_nameβ allowed (different users might name their servers the same thing); no uniqueness constraint - Checks existing claim β if this server (matched by public key fingerprint) already has a pending (unclaimed) claim, returns the existing claim_code rather than issuing a new one (avoids burning claim codes on retries)
Response β
{
"claim_code": "ABCD-1234",
"expires_in": 600,
"claim_id": "550e8400-e29b-41d4-a716-446655440000",
"hub_base_url": "https://hub.example.com"
}Error Responses β
| HTTP Status | Error Code | Meaning |
|---|---|---|
400 | SERVER_KEY_INVALID | JWK malformed or not Ed25519 |
400 | HUB_PROTOCOL_UNSUPPORTED | protocol_version not "v1" |
400 | SERVER_VERSION_INCOMPATIBLE | Server version below hub's minimum |
500 | HUB_INTERNAL_ERROR | Unexpected hub error |
5. Hub β User: Claim Flow (Web UI) β
User Action β
- User logs into
https://hub.example.com - Clicks "Claim a Server" button
- Enters the 8-char claim code (
ABCD-2345) into a form field - Submits
Internal Hub Action β
POST /api/v1/server-claims/claim
{
"claim_code": "ABCD-1234"
}The hub uses the user's authenticated session (not an explicit user_id field) to associate the server with the currently logged-in user.
Atomic Claim Process β
BEGIN TRANSACTION
1. SELECT * FROM server_claims
WHERE claim_code = ? AND expires_at > NOW()
FOR UPDATE
2. IF not found β ROLLBACK, return CLAIM_CODE_NOT_FOUND or CLAIM_CODE_EXPIRED
3. IF already claimed_by IS NOT NULL β ROLLBACK, return CLAIM_CODE_ALREADY_CLAIMED
4. UPDATE server_claims SET claimed_by = ?, claimed_at = NOW()
5. INSERT INTO servers (id, user_id, server_name, version, public_key_jwk,
hostname_candidates, status, last_seen_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'online', NOW(), NOW())
6. DELETE FROM server_claims WHERE id = ? β code is single-use
COMMITResponse on Success β
{
"enrollment_jwt": "eyJhbGciOiJFZERTQSJ9...",
"hub_jwks_url": "https://hub.example.com/.well-known/jwks.json",
"server_id": "550e8400-e29b-41d4-a716-446655440000"
}Enrollment JWT β
The enrollment_jwt is signed by the hub with its own Ed25519 key.
Claims:
| Claim | Value | Description |
|---|---|---|
iss | phlix-hub | Issuer identifier |
sub | server_id | UUID assigned by hub |
aud | server | Audience: this token is for the server |
exp | now + 7d | 7-day validity β server must re-enroll before expiry |
iat | now | Issued-at |
kid | key ID | Which hub signing key was used |
hub_base_url | https://hub.example.com | Hub API base for heartbeat destination |
server_id | UUID | Same as sub |
The server stores this token and uses it to authenticate heartbeats.
Error Responses β
| HTTP Status | Error Code | Meaning |
|---|---|---|
404 | CLAIM_CODE_NOT_FOUND | Code doesn't exist |
410 | CLAIM_CODE_EXPIRED | Code TTL elapsed |
409 | CLAIM_CODE_ALREADY_CLAIMED | Already claimed by another user |
401 | UNAUTHENTICATED | User not logged in |
6. Heartbeat β
Endpoint β
POST https://hub.example.com/api/v1/servers/{server_id}/heartbeat
Authorization: Bearer <enrollment_jwt>
Accept-Phlix-Protocol: v1
Content-Type: application/jsonPayload β
{
"server_id": "550e8400-e29b-41d4-a716-446655440000",
"version": "0.11.0",
"timestamp": 1747430400,
"uptime_seconds": 86400,
"active_sessions": 2,
"active_transcodes": 1,
"hostname_candidates": [
"https://192.168.1.100:32400",
"https://alice-nas.local:32400",
"https://alice-nas.duckdns.org:32400"
],
"libraries": [
{ "id": "lib-uuid-1", "name": "Movies", "item_count": 1247 },
{ "id": "lib-uuid-2", "name": "TV Shows", "item_count": 312 }
],
"capabilities": ["direct-play", "transcode-h264", "transcode-h265", "syncplay"]
}Field Descriptions β
| Field | Type | Required | Description |
|---|---|---|---|
server_id | string (UUID) | Yes | Hub-assigned server UUID |
version | string | Yes | Server semver |
timestamp | int | Yes | UNIX seconds at send time |
uptime_seconds | int | Yes | Process uptime |
active_sessions | int | Yes | Concurrent playback sessions |
active_transcodes | int | Yes | Concurrent active transcode processes |
hostname_candidates | list<string> | Yes | All hostnames the server thinks it's reachable at; first publicly reachable is used |
libraries | list<object> | No | Summary of connected libraries with item counts |
capabilities | list<string> | No | Server capabilities for hub dashboard display |
Hub Behavior on Heartbeat β
- Validates the
enrollment_jwt(signature + expiry) - Updates
servers.last_seen_at = NOW() - Updates
servers.status = 'online' - Updates
servers.version,servers.hostname_candidatesfrom payload - If
server_idis unknown β404 SERVER_NOT_FOUND
Heartbeat Frequency β
- Server sends heartbeat every 60 seconds
- If hub misses 3 consecutive heartbeats (3 minutes), it marks the server as
offline - β A per-server heartbeat interval is not implemented. The
servers.heartbeat_intervalcolumn exists (default 60) but nothing writes it and nothing reads it β the server never sends the field, and the reaper applies a single fixed 180-second threshold to every server.
7. Hub JWKS β
URL β
https://hub.example.com/.well-known/jwks.jsonServed by the hub's Workerman HTTP server. Same format as server JWKS.
Document Format β
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "hN3d2GhVKGYoCpad3qDQD0gRNYVWg0Wmg2TTKJSbZ-g",
"kid": "2026-05-17T00:00:00Z",
"use": "sig",
"alg": "EdDSA"
}
]
}Key Rotation β
Hub operator triggers rotation via admin CLI. Overlap window: 24 hours during which both old and new signing keys are accepted.
8. User-Session JWT (Delegated Auth) β
Not yet implemented on the hub
The server side is live: HubJwtValidator exists and expects iss == 'phlix-hub' and aud == 'phlix-server'. But no hub code path mints a user-session JWT β the only Ed25519 token the hub issues today is the enrollment JWT, whose audience is server, and hub_user_id appears nowhere in phlix-hub. As things stand the server's validator would reject every token the hub currently produces. This section describes the intended contract.
Issuance β
When a user who has claimed servers wants to access one remotely, the hub issues a JWT that:
- Identifies the user (
sub: user_id) - Authorizes access to a specific server (
server_idclaim) - Is signed by the hub (
iss: phlix-hub)
Token Claims β
{
"iss": "phlix-hub",
"sub": "user-uuid",
"aud": "phlix-server",
"exp": 1747434000,
"iat": 1747430400,
"kid": "2026-05-17T00:00:00Z",
"server_id": "550e8400-e29b-41d4-a716-446655440000",
"scope": ["library:read", "playback:write"],
"jti": "unique-token-id"
}Server Validation of Hub-Minted Tokens β
- Server fetches JWKS from
hub_jwks_url(cached, refreshed every 15 minutes or on 401 response) - Extracts the
kidfrom the token header - Looks up the matching key in the JWKS
- Validates the signature with EdDSA
- Validates
iss == 'phlix-hub' - Validates
aud == 'phlix-server' - Validates
server_idmatches the server's own ID (prevents token from one server being used against another) - Validates
exp,iat,nbfas usual
9. Protocol Versioning β
Header β
Every request and response on pairing-related endpoints carries:
Accept-Phlix-Protocol: v1If the hub receives a request without this header or with an unexpected value, it returns:
{ "error": "HUB_PROTOCOL_UNSUPPORTED", "message": "Accept-Phlix-Protocol: v1 required" }Version Compatibility Matrix β
| Protocol Version | Hub Min | Server Min | Notes |
|---|---|---|---|
v1 | 1.0.0 | 0.11.0 | Initial release |
Future versions will increment the header value and include migration instructions.
10. Error Code Reference β
All pairing protocol errors use this envelope:
{
"error": "ERROR_CODE",
"message": "Human-readable description",
"details": {} // optional additional context
}Server-Side Errors (Server β Hub requests) β
| Error Code | HTTP Status | Description |
|---|---|---|
SERVER_KEY_INVALID | 400 | Server's JWK is malformed or not Ed25519 |
HUB_PROTOCOL_UNSUPPORTED | 400 | Hub doesn't support the server's protocol version |
SERVER_VERSION_INCOMPATIBLE | 400 | Server version below hub minimum |
HUB_UNREACHABLE | 503 | Server cannot reach hub (network issue) |
HUB_JWKS_FETCH_FAILED | 503 | Server cannot fetch hub's JWKS |
Hub-Side Errors (Hub β Server or User β Hub requests) β
| Error Code | HTTP Status | Description |
|---|---|---|
CLAIM_CODE_NOT_FOUND | 404 | Claim code doesn't exist in DB |
CLAIM_CODE_EXPIRED | 410 | Claim code TTL has elapsed |
CLAIM_CODE_ALREADY_CLAIMED | 409 | Claim code already used by another user |
SERVER_NOT_FOUND | 404 | Server ID not known to hub |
UNAUTHENTICATED | 401 | User not logged in to hub |
AUTHORIZATION_FAILED | 403 | User doesn't own this server |
ENROLLMENT_TOKEN_EXPIRED | 401 | Server's enrollment JWT has expired |
HUB_INTERNAL_ERROR | 500 | Unexpected hub error |
11. Security Considerations β
Claim Code Security β
- 8-char alphanumeric is ~40 bits of entropy
- β Claim submission is not rate-limited today.
POST /api/v1/server-claims/claimruns behind[AuthMiddleware, HubProtocolMiddleware]only, and/server-claims/newbehindHubProtocolMiddlewarealone. Neither is a limiter, and there is no attempt counter in the database. The hub's rate-limit profiles cover login, proxy, heartbeat, JWKS, relay-connect, client-mount, MCP and Alexa β there is no claim surface. The 2^40 code space and the 10-minute TTL are the only brute-force defences. - Single-use: the claim is marked
claimed_byunderSELECT β¦ FOR UPDATE, so a replay getsCLAIM_CODE_ALREADY_CLAIMED - 10-minute TTL prevents indefinite exposure
Token Storage β
- Server stores the enrollment JWT and the hub JWKS URL together in a single file,
config/hub-enrollment.json(mode 0600), alongsideserver_id,hub_base_urlandenrolled_at. There is no separatehub-enrollment-tokenorhub-jwks-urlfile. - Hub stores user session JWTs in httpOnly cookies (not localStorage)
Signature Verification β
- Server always validates hub JWT signatures against JWKS from
hub_jwks_urlβ never hardcodes the hub's public key - Server caches JWKS for 15 minutes; refetches on 401 to handle rotation
- Ed25519 signature verification is constant-time and resistant to timing attacks
Relay Security β
- The relay tunnel (Phase C.6) uses the existing enrollment JWT to authenticate the server connection
- Each relayed request carries a separate per-request token (not the enrollment JWT directly)
- Hub validates the per-request token before forwarding any bytes
12. Database Schema (Hub-Side) β
See plans/expansion/c.3-hub-registry.md Β§3 for full schema details.
13. Cross-Reference β
- Step C.2 implements
Phlix\Hub\HubClienton the server side - Step C.3 implements the hub registry endpoints on the hub side
- Step C.4 implements the "My Servers" dashboard using registry data
- Step C.5 implements delegated auth (hub JWKS + user-session JWTs)
- Step C.6 implements the relay tunnel
- Step C.7 implements UPnP + port-forward helper
- Step C.8 implements public hostname claim
- Step C.9 implements shared libraries
- phlix-shared provides:
ClaimRequest,ClaimResponse,ServerInfoDto,HeartbeatDtoDTOs shipped in B.3 v0.2.0