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
$privateKey = sodium_crypto_sign_keypair();        // 64-byte seed + 32-byte pub
$secretKey  = substr($privateKey, 0, 32);          // first 32 bytes = secret
$publicKey  = substr($privateKey, 32);            // last 32 bytes = public

// Store PEM
file_put_contents($keyPath, "-----BEGIN ED25519 PRIVATE KEY-----\n"
    . base64_encode($secretKey) . "\n-----END ED25519 PRIVATE KEY-----\n");
chmod($keyPath, 0600);

Key Rotation ​

  • Keys are rotated when the server operator explicitly triggers it (e.g., php scripts/rotate-hub-key.php)
  • 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>:32400/.well-known/jwks.json
https://<server-hostname>:32400/.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 (ISO 8601 timestamp; changes on rotation)
  • 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 6-character alphanumeric, uppercase letters + digits (excluding 0, O, I, 1 to avoid ambiguity):

Pattern: [A-Z2-9]{4}-[A-Z2-9]{4}
Example: "ABCD-1234"
  • 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 (configurable)
  • Single-use: atomic validation deletes the code on successful claim

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

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 6-char claim code (ABCD-1234) 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
  • Server can request a longer interval by passing "heartbeat_interval": 300 in the payload; hub will only mark offline after 3 Γ— interval seconds

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

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": "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 == '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 ​

  • 6-char alphanumeric is ~40 bits of entropy β€” sufficient for a short-lived, rate-limited code entry
  • Hub rate-limits claim attempts: max 5 attempts per IP per 10 minutes
  • Single-use: atomic delete on successful claim prevents replay
  • 10-minute TTL prevents indefinite exposure

Token Storage ​

  • Server stores enrollment JWT in config/hub-enrollment-token (mode 0600)
  • Hub stores user session JWTs in httpOnly cookies (not localStorage)
  • Server stores hub JWKS URL in config/hub-jwks-url (plain text, mode 0644)

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