-
Notifications
You must be signed in to change notification settings - Fork 6
Remote Link Internals
How two PadForge PCs share a device: UDP discovery, the X25519 and Ed25519 pairing handshake with a six-digit code, the sealed datagram transport, input streaming, and the reverse output relay.
This is the developer-side companion to Remote Link (the user guide) and issue #138.
The engine logic lives in PadForge.Engine/RemoteLink/ (namespace PadForge.Engine.RemoteLink). The App wires it up and provides the capture taps.
| Area | Files |
|---|---|
| Discovery | LinkDiscovery.cs |
| Transport and lifecycle |
LinkServer.cs, LinkConnection.cs, LinkSession.cs, TcpControlChannel.cs, AntiReplayWindow.cs
|
| Crypto |
LinkHandshake.cs, PeerCrypto.cs, PeerIdentity.cs, IdentityProtector.cs
|
| Trust |
PeerTrustStore.cs, PeerTrust.cs
|
| Devices and codecs |
RemotePeerDevice.cs, CustomInputStateCodec.cs, OutputEffectCodec.cs
|
| App relay and wiring |
PadForge.App/Common/Input/RemoteLinkOutputRouter.cs, PadForge.App/Services/InputService.cs, SettingsService.cs
|
| Capture taps |
InputManager.Step2/Step4b/Step5/Step1.*.cs, SonyEffectWriter.cs, AudioPassthroughService.cs
|
The transport is abstracted behind ILinkControlChannel, so an in-memory test harness and the TCP socket share one path.
LinkDiscovery is a UDP broadcast beacon, not mDNS. It binds IPAddress.Any:27501, announces every two seconds to IPAddress.Broadcast:27501, and prunes a peer after ten seconds of silence. Because it broadcasts, it is same-subnet only. The beacon carries a four-byte magic, a version, the link port, the sender's identity fingerprint (hex), and the self-asserted machine name. A forged beacon can only put a name in the "Nearby PCs" list. The crypto gate still controls admission. The receive loop filters out the PC's own fingerprint and raises PeersChanged.
LinkServer runs a TcpListener for the pairing handshake and a UDP socket for data, both on the link port (default 27500). It caps pending handshakes, times them out after three minutes, sends keepalives, and reaps idle connections. A reconnecting peer replaces its prior connection.
LinkConnection runs the handshake (initiator or responder) and then owns a LinkSession. LinkSession seals every datagram: a 14-byte authenticated header (message type and epoch, slot, a 32-bit sequence, a microsecond timestamp) plus ChaCha20-Poly1305 ciphertext and a 16-byte tag. The sequence doubles as the nonce counter, and the two directions use disjoint salts so both peers share one key without a nonce collision. Receive is verify-then-window: check the tag, then run AntiReplayWindow, so a forged sequence never advances the replay state. The session hard-stops before the 32-bit sequence wraps and forces a rekey.
LinkMessageType values: Input (an absolute device-state frame, owner to consumer), Keepalive, Output (a tagged effect frame, consumer to owner), Audio (a speaker PCM block), and DeviceList (the owner's exposed-device set, resent on change and every two seconds for hot-plug sync). A legacy Haptic type is superseded by Output and unused. The initial device lists are also exchanged during the handshake under a separate control key. Each device entry carries the stable slot, a stable id, the name, VID and PID, capability counts, a capability bitmask, and the input-device type.
LinkHandshake is an authenticated key exchange over TCP, in four messages: commit, reveal from the responder, reveal from the initiator, confirm.
- Ephemeral X25519 per side gives forward secrecy.
-
Commit before reveal. The commit is
SHA256over the commit label, the ephemeral public key, a nonce, the capabilities, and the static public key. Binding the static key into the commit closes the one SAS input a relay attacker could otherwise grind. The responder recomputes and checks it with a constant-time compare before trusting the reveal. -
Ed25519 static-key signatures over the transcript hash. The transcript hash is
SHA256over the three handshake messages, each field length-prefixed so boundaries are unambiguous. Both sides sign it and verify the other. Capabilities and version are folded into the signed transcript, so a downgrade fails at the signature check. - Six-digit SAS. A hash of the transcript reduced modulo one million, formatted to six digits. It is compared out of band on first pairing only.
- Session key via HKDF over the shared secret, salted by the transcript hash. The connection then derives separate control and data keys.
- Fail-closed. Every malformed, out-of-order, or unauthenticated input throws, and no device is created on a failed handshake.
Admission runs PeerTrustStore.Decide(peerStaticKey). First contact prompts the user (the RemoteLinkPairDialog). A known key reconnects with no prompt, and the signature still proves possession.
PeerTrust persists per peer as XML attributes: the pinned Ed25519 public key (an identity, not a secret), the name, the self-asserted host name, the pairing time, a reconnect-enabled flag (default true), and the gamepad-only flag. The fingerprint is the SHA-256 of the public key.
PeerTrustStore is the in-memory authority. Decide returns first-contact (unknown, never auto-trusted), known-auto-select, or known-manual. Lookups use a constant-time compare. An unknown key defaults to gamepad-only restricted, which is the fail-safe.
IdentityProtector stores this PC's own identity in one of three modes. Secure (the default) wraps the private key with DPAPI at machine scope, so any Windows user on the PC can use it but it does not move to another machine. Portable, password protected wraps it with PBKDF2-SHA256 (600,000 iterations) and AES-256-GCM. Portable, open stores it in the clear. The blob is self-describing, with a version and mode header. LoadOrMint mints only when the store is empty or corrupt and never overwrites a locked identity, so a password prompt or a wrong-machine state is surfaced to the caller rather than clobbered. A mode switch re-wraps the same key, so the fingerprint and all pairings survive.
On the owner, a stream timer running near 125 Hz calls LinkServer.PushLocalFrame per exposed device, which encodes the state through CustomInputStateCodec and seals an Input datagram. The codec is absolute: every frame is the full state through a present-block bitmask with neutral-omit, so packet loss self-heals and a centered idle pad is three bytes.
On the consumer, the device list yields a RemotePeerDevice per exposed device. The link server's DeviceConnected event calls InputManager.RegisterPeerDevice, which runs it through FindOrCreateUserDevice and LoadFromExternalDevice. A RemotePeerDevice is an ISdlInputDevice, so it flows through the normal six-step pipeline like any local source. Inbound Input datagrams decode into a back buffer that swaps under a lock, newest-wins by timestamp. After a three-second stale window the device reads as offline and releases any held inputs. The device identity is salted by the peer fingerprint (peer://{fingerprint}/{deviceId}), so two peers sharing the same controller model never alias through the reconnect fallback.
The consumer runs the full config-applied output pipeline for a peer:// device. The final hardware write fails (the handle is zero), so each write chokepoint hands its config-baked payload to RemoteLinkOutputRouter instead:
-
ShipSonyEffect(inSonyEffectWriter) strips the report id and encodes the DualSense or DualShock 4 effect body. -
ShipVibration(Step 2) encodes the fullVibration(motors, impulse triggers, directional and condition force). -
ShipWheel(Step 2) encodes a semantic wheel frame (force, condition, periodic, range, auto-center, RPM-LED mask). -
ShipAudio(inAudioPassthroughService) ships the speaker PCM block on theAudiodatagram.
The router maps the peer:// path to a target (owner fingerprint and link slot, fixed at connect), dedups exact repeats per channel, and sends through PushOutputEffect or PushAudio.
On the owner, OutputReceived decodes the frame, resolves the link slot to the physical source device, takes the sole-writer lease, and applies by kind: a Sony effect through SDL_SendGamepadEffect, a vibration through ForceFeedbackState.SetDeviceForces (a Fanatec pedal re-routes to its pedal-rumble writer), a wheel frame through ApplyRemoteWheel (which re-encodes with the owner's own vendor writers, see Wheel Force Feedback), and audio through AudioPassthroughService.FeedRemoteAudio (see Controller Audio Internals).
Sole-writer guard. A device can be both shared out and mapped locally, and output cannot merge. An inbound relay frame stamps the local path with a three-second lease, and the owner's local chokepoints skip their own writes while the lease is fresh. A device that is only lent out (not also mapped locally) has no local writer, so there is no contention.
InputManager.SetDeviceRestricted populates the restricted-device set from each peer's gamepad-only flag, set before the device goes online. IsSlotRestricted(slot) is true when any online restricted device maps to that slot, and it early-outs when no peer is restricted. Three chokepoints honor it:
- The KBM virtual controller (Step 5) submits a neutral state for a restricted slot, releasing anything held.
- Macros (Step 4b) are restricted when the slot is restricted or a restricted device is a macro trigger.
- The four Win32
SendInputemitters (key, mouse move, mouse button, scroll, in Step 4b) early-return while the macro slot is restricted.
So a gamepad-only peer can drive gamepad slots but never reach the keyboard, mouse, or scroll.
AppSettingsData persists RemoteLinkIdentityPrivate and RemoteLinkIdentityPublic, RemoteLinkIdentityProtection (default Secure), RemoteLinkPeers (the PeerTrust array), EnableRemoteLink, RemoteLinkAutoReconnect (default true), and RemoteLinkPort (default 27500, clamped to 1024-65535). These load into a runtime record on SettingsService and into the Dashboard view model.
The auto-reconnect dial runs when a peer is discovered. It requires a trusted entry with reconnect enabled, skips a peer already connected, applies a lower-fingerprint-dials rule so only one side initiates, and throttles per peer with a five-second cooldown. A passing peer triggers a connect with the PC's exposed devices.
- Remote Link: the user guide for this feature.
-
Input Pipeline: a
RemotePeerDeviceis anISdlInputDeviceand runs through the normal six steps. - Wheel Force Feedback: the owner re-encodes a relayed wheel frame with the vendor writers.
- Controller Audio Internals: the relayed speaker audio path.
-
Services Layer:
InputServicewiring and the auto-reconnect dial. - Settings and Serialization: the persisted Remote Link fields.