Skip to content

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 key

The 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:

php
$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=Ed25519

The 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 ​

json
{
  "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 identifier
  • x β€” base64url-encoded 32-byte public key
  • kid β€” 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 8601 kid is generated only in the fallback case where no key file exists at all.)
  • use: "sig" β€” signature key
  • alg: "EdDSA" β€” algorithm identifier

Serving the JWKS ​

The Workerman HTTP server handles this directly:

php
$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 UPDATE in an explicit transaction and marked claimed_by; a second attempt gets CLAIM_CODE_ALREADY_CLAIMED. The row itself is deleted later, once the server retrieves its enrollment JWT via GET /api/v1/server-claims/{claimId} β€” not at claim time.

Generation Algorithm ​

php
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/new

Request Headers ​

Accept-Phlix-Protocol: v1
Content-Type: application/json

Request Body ​

json
{
  "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 ​

FieldTypeRequiredDescription
server_namestringYesOperator-chosen friendly name shown on hub dashboard
versionstringYesServer semver (e.g., 0.11.0). Hub may reject incompatible versions
public_keysobjectYesJWK of the server's Ed25519 public key (kid references current active key)
hostname_candidateslist<string>YesHostnames/IPs the server believes it is reachable at. Hub uses the first publicly reachable one; falls back to relay
protocol_versionstringYesFixed at "v1". Hub validates this header value

Why :32400 in the candidates but :8096 for JWKS. The LAN, mDNS and public candidates are built by PortForwardService::discoverHostnameCandidates() from the port-forwarding port (PHLIX_EXTERNAL_PORT, default 32400), not from the port the HTTP server binds (config/server.php server.port, default 8096). With both defaults left alone the advertised candidates point at a port nothing is listening on β€” set PHLIX_EXTERNAL_PORT to match server.port. See Environment variables.

Hub Validation on Claim Request ​

Before accepting a claim request, the hub:

  1. Validates protocol_version β€” must be "v1"; rejects with HUB_PROTOCOL_UNSUPPORTED if not
  2. Checks version against hub_min_server_version β€” rejects if server version is too old with SERVER_VERSION_INCOMPATIBLE
  3. Validates the JWK structure β€” must be a well-formed Ed25519 public key; rejects with SERVER_KEY_INVALID if malformed
  4. Checks for duplicate server_name β€” allowed (different users might name their servers the same thing); no uniqueness constraint
  5. 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 ​

json
{
  "claim_code": "ABCD-1234",
  "expires_in": 600,
  "claim_id": "550e8400-e29b-41d4-a716-446655440000",
  "hub_base_url": "https://hub.example.com"
}

Error Responses ​

HTTP StatusError CodeMeaning
400SERVER_KEY_INVALIDJWK malformed or not Ed25519
400HUB_PROTOCOL_UNSUPPORTEDprotocol_version not "v1"
400SERVER_VERSION_INCOMPATIBLEServer version below hub's minimum
500HUB_INTERNAL_ERRORUnexpected hub error

5. Hub β†’ User: Claim Flow (Web UI) ​

User Action ​

  1. User logs into https://hub.example.com
  2. Clicks "Claim a Server" button
  3. Enters the 8-char claim code (ABCD-2345) into a form field
  4. 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
COMMIT

Response on Success ​

json
{
  "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:

ClaimValueDescription
issphlix-hubIssuer identifier
subserver_idUUID assigned by hub
audserverAudience: this token is for the server
expnow + 7d7-day validity β€” server must re-enroll before expiry
iatnowIssued-at
kidkey IDWhich hub signing key was used
hub_base_urlhttps://hub.example.comHub API base for heartbeat destination
server_idUUIDSame as sub

The server stores this token and uses it to authenticate heartbeats.

Error Responses ​

HTTP StatusError CodeMeaning
404CLAIM_CODE_NOT_FOUNDCode doesn't exist
410CLAIM_CODE_EXPIREDCode TTL elapsed
409CLAIM_CODE_ALREADY_CLAIMEDAlready claimed by another user
401UNAUTHENTICATEDUser 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/json

Payload ​

json
{
  "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 ​

FieldTypeRequiredDescription
server_idstring (UUID)YesHub-assigned server UUID
versionstringYesServer semver
timestampintYesUNIX seconds at send time
uptime_secondsintYesProcess uptime
active_sessionsintYesConcurrent playback sessions
active_transcodesintYesConcurrent active transcode processes
hostname_candidateslist<string>YesAll hostnames the server thinks it's reachable at; first publicly reachable is used
librarieslist<object>NoSummary of connected libraries with item counts
capabilitieslist<string>NoServer capabilities for hub dashboard display

Hub Behavior on Heartbeat ​

  1. Validates the enrollment_jwt (signature + expiry)
  2. Updates servers.last_seen_at = NOW()
  3. Updates servers.status = 'online'
  4. Updates servers.version, servers.hostname_candidates from payload
  5. If server_id is 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_interval column 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.json

Served by the hub's Workerman HTTP server. Same format as server JWKS.

Document Format ​

json
{
  "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:

  1. Identifies the user (sub: user_id)
  2. Authorizes access to a specific server (server_id claim)
  3. Is signed by the hub (iss: phlix-hub)

Token Claims ​

json
{
  "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 ​

  1. Server fetches JWKS from hub_jwks_url (cached, refreshed every 15 minutes or on 401 response)
  2. Extracts the kid from the token header
  3. Looks up the matching key in the JWKS
  4. Validates the signature with EdDSA
  5. Validates iss == 'phlix-hub'
  6. Validates aud == 'phlix-server'
  7. Validates server_id matches the server's own ID (prevents token from one server being used against another)
  8. Validates exp, iat, nbf as usual

9. Protocol Versioning ​

Every request and response on pairing-related endpoints carries:

Accept-Phlix-Protocol: v1

If the hub receives a request without this header or with an unexpected value, it returns:

json
{ "error": "HUB_PROTOCOL_UNSUPPORTED", "message": "Accept-Phlix-Protocol: v1 required" }

Version Compatibility Matrix ​

Protocol VersionHub MinServer MinNotes
v11.0.00.11.0Initial release

Future versions will increment the header value and include migration instructions.


10. Error Code Reference ​

All pairing protocol errors use this envelope:

json
{
  "error": "ERROR_CODE",
  "message": "Human-readable description",
  "details": {}  // optional additional context
}

Server-Side Errors (Server β†’ Hub requests) ​

Error CodeHTTP StatusDescription
SERVER_KEY_INVALID400Server's JWK is malformed or not Ed25519
HUB_PROTOCOL_UNSUPPORTED400Hub doesn't support the server's protocol version
SERVER_VERSION_INCOMPATIBLE400Server version below hub minimum
HUB_UNREACHABLE503Server cannot reach hub (network issue)
HUB_JWKS_FETCH_FAILED503Server cannot fetch hub's JWKS

Hub-Side Errors (Hub β†’ Server or User β†’ Hub requests) ​

Error CodeHTTP StatusDescription
CLAIM_CODE_NOT_FOUND404Claim code doesn't exist in DB
CLAIM_CODE_EXPIRED410Claim code TTL has elapsed
CLAIM_CODE_ALREADY_CLAIMED409Claim code already used by another user
SERVER_NOT_FOUND404Server ID not known to hub
UNAUTHENTICATED401User not logged in to hub
AUTHORIZATION_FAILED403User doesn't own this server
ENROLLMENT_TOKEN_EXPIRED401Server's enrollment JWT has expired
HUB_INTERNAL_ERROR500Unexpected 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/claim runs behind [AuthMiddleware, HubProtocolMiddleware] only, and /server-claims/new behind HubProtocolMiddleware alone. 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_by under SELECT … FOR UPDATE, so a replay gets CLAIM_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), alongside server_id, hub_base_url and enrolled_at. There is no separate hub-enrollment-token or hub-jwks-url file.
  • 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\HubClient on 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, HeartbeatDto DTOs shipped in B.3 v0.2.0

BSD-3-Clause