Skip to content

Server Identity Pinning

Every rVPN server is identified by a long-lived Ed25519 identity key. The client pins that key the first time it connects to a profile and refuses to connect if the pinned key ever changes without a valid rotation proof. This is the same trust model as SSH, extended with a chained-signature rotation ceremony so operators can rotate keys without breaking existing clients.


Every pin is written in a single canonical encoding:

ik:1:<52 base32 characters>
  • ik: — namespace, reserved for future key types.
  • 1 — version, so a legitimate rotation can bump to ik:2:… unambiguously.
  • The body is sha256(identity_key) in RFC 4648 base32, lower-case, no padding. 52 characters, ASCII-safe, QR-friendly.

Example:

ik:1:d4rgmp5b7ta6qmxi2mccwkjq4qxopfxzr7qivbfgu4wjycmuxnla

The desktop client used to store a 32-character hex fingerprint. It still reads those legacy values on first connect, then rewrites the pin in the ik:1: form on save — no configuration change required.


On iOS, macOS, and Android the pin lives per-profile inside the profile itself, in the Server Fingerprint field. There is no separate known_hosts.json.

  • First connect with Server Fingerprint empty and Trust on First Use on: the app captures the server’s pin and stores it on the profile.
  • Subsequent connects: the tunnel refuses to open unless the server’s pin matches the stored value.
  • Mismatch: the app shows the Server identity changed dialog with the pinned and received pins side-by-side, and three choices — Cancel, Delete Profile, or Trust New Identity.

To pin the identity explicitly before the first connect, paste the pin from your server operator into Server Fingerprint in the profile editor.


The desktop rvpn client keeps pins in known_hosts.json (path configurable) and layers a config-level fingerprint on top. See the [server_identity] block in Client Config for the full field reference; the essentials:

  • fingerprint — set to an ik:1:… value to enforce a specific identity regardless of what’s stored in known_hosts.json.
  • trust_on_first_use — default true. Capture on first connect.
  • strict_mode — default false. Set to true to refuse unknown servers entirely; you must set fingerprint before the first connect.
  • strict — default true. When false, a mismatch logs a warning instead of aborting the connection.

The client also migrates any legacy hex fingerprint it finds in known_hosts.json to the canonical ik:1: form on save, preserving the first_seen timestamp.


When an operator rotates the server’s identity key, the prekey bundle grows two fields the client uses to prove continuity:

  • identity_key_version — monotonic; new identity ⇒ +1.
  • rotation_signature — Ed25519 signature by the previous identity over the new public key bytes + new version number.

A client that has pinned version N will:

  1. See a bundle whose identity doesn’t match its pin,
  2. Verify identity_key_version == N + 1,
  3. Verify the rotation signature using the pinned identity as the verifying key,
  4. If both hold, silently update the stored pin to the new value — no dialog, the connection just works.

If the client sees an unsigned rotation or a version skip, it falls through to the mismatch dialog described above.

Fresh servers ship a v1 bundle with no rotation signature:

Terminal window
rvpn-server keygen
rvpn-server prekey-bundle

To rotate, generate a new identity and sign the new bundle with the old key:

Terminal window
# Keep the old identity somewhere reachable
mv server_identity.key old_identity.key
# Generate the new one
rvpn-server keygen --output server_identity.key
# Publish a v2 bundle signed by the old identity
rvpn-server prekey-bundle \
--identity server_identity.key \
--output prekey-bundle.json \
--rotate-from old_identity.key \
--from-version 1

--rotate-from and --from-version must be supplied together. The server refuses to publish an unsigned rotation. Ship the new bundle; existing clients update silently, new clients pin the new key on first use.

Keep old_identity.key archived — you only need it long enough to publish the rotated bundle, but destroying it locks you out of ever signing another rotation from that version.


Both mobile and desktop profile exports carry the serverFingerprint field. When you import a .rvpn profile that already has a pin set, the sender has vouched for the server’s identity out-of-band — compare the pin they published with what the import sheet shows before saving.

If the imported profile has no pin, the tunnel captures one on the first successful connect just as if you’d created the profile locally.


Protected against

  • Passive MITM after the first successful connect.
  • A malicious operator taking over your server’s domain or IP and pointing it at a different backend.
  • Replay of a stolen prekey bundle from a different server.

Not protected against

  • Active MITM on the very first connect. Classic TOFU limitation. Mitigate by importing a profile that already carries the pin, or by pasting the pin from an out-of-band channel before you connect.
  • Tamper of the on-device profile store on rooted / jailbroken systems. Stock iOS sandboxes profiles; stock Android keeps them app-private.

If you need to deliberately replace a server’s identity — a compromise recovery, say — the mismatch dialog is the correct path. Publish the new pin out-of-band, tell your users to accept the change, and they’ll be back on the new key with a fresh pin.