Technical Details

Blind pair request

Overview

Blind Pairing is a privacy-preserving mechanism that allows players to pair with each other by right clicking another character, and selecting the "Send Pair Request" context menu item, without revealing their identities to the network until both parties have mutually agreed to pair. This document explains the complete flow from client to server, the cryptographic constructs involved, and the privacy guarantees provided.

What is Blind Pairing?

Blind Pairing allows a player to send a pair request to another player they can see in-game without knowing their HonseFarm UID or server. The system uses the target's Content ID (a unique identifier assigned by the game) to generate a hash that can be matched across the federated server network.

Key Properties

  1. Mutual Consent Required: A pairing only occurs when BOTH players have sent pair requests to each other
  2. Privacy Preserving: Servers that don't have a matching user learn almost nothing about the request
  3. Anonymous Until Matched: The target's identity is only revealed to the requester if/when they mutually pair

The Player Hash (Character Hash)

What It Is

The Player Hash (also called Character Hash or charaIdent) is a SHA256 hash of the player's Content ID.

Content ID → SHA256 → 64-character hexadecimal string

Generation (Client-Side)

// In DalamudUtilService.cs
public async Task<string> GetPlayerNameHashedAsync()
{
    return await RunOnFrameworkThread(() => _cid.Value.ToString().GetHash256()).ConfigureAwait(false);
}

// The hash function (Crypto.cs)
public static string GetHash256(this string stringToHash)
{
    return BitConverter.ToString(
        SHA256.ComputeHash(Encoding.UTF8.GetBytes(stringToHash))
    ).Replace("-", "", StringComparison.Ordinal);
}

Example

Content ID: 1234567890123456
SHA256 Hash: A3F7E2C9D4B6A8E1F0C3D5B7A9E2F4C6D8B1A3E5F7C9D2B4A6E8F0C1D3B5A7E9
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
            64 characters (256 bits in hexadecimal)

The Two-Phase Lookup Protocol

To preserve privacy, blind pair requests use a two-phase protocol:

Phase 1: Partial Hash Broadcast

Only a small portion of the target's hash is sent to federated servers.

// BlindPairConstants.cs
public static class BlindPairConstants
{
    public const int PartialHashLength = 6;   // Only 6 characters
    public const int HashMinIndex = 19;       // Start at 30% of the hash
    public const int HashMaxIndex = 38;       // End at 70% of the hash
}

How it works:

  1. The client generates a random index between 19-38 (the middle 40% of the 64-character hash)
  2. Extracts 6 characters starting at that random position
  3. Sends only this 6-character partial hash to all federated servers
Full hash:  A3F7E2C9D4B6A8E1F0C3D5B7A9E2F4C6D8B1A3E5F7C9D2B4A6E8F0C1D3B5A7E9
                               ↑ index 19      ↑ index 38
            Middle 40% range:  [D5B7A9E2F4C6D8B1A3E5]
            
Random selection (index 25, length 6): E2F4C6

Phase 2: Full Hash Exchange

Only the server that finds a matching partial hash receives the full request.

public record PartialBlindPairRequestDto
{
    public string PartialCharacterHash { get; init; }  // 6 chars
    public int PartialHashIndex { get; init; }          // 19-38
}

public record FullBlindPairRequestDto
{
    public string RequesterCharacterHash { get; init; }  // Full 64 chars
    public string TargetCharacterHash { get; init; }     // Full 64 chars
}

Complete Flow: Client to Server(s)

Step 1: User Right-Clicks Target in Game

┌─────────────────────────────────────────────────────────────────┐
│  GAME CLIENT                                                     │
│                                                                  │
│  User right-clicks on another player → Context menu appears     │
│  "Send Pair Request" option shown (by BlindPairManager)         │
│                                                                  │
│  Target's Content ID obtained: 1234567890123456                 │
│  Hashed: A3F7E2C9D4B6A8E1F0C3D5B7A9E2F4C6D8B1A3E5F7C9D2B4...    │
└─────────────────────────────────────────────────────────────────┘

The BlindPairManager handles the context menu:

private void OnMenuOpened(IMenuOpenedArgs args)
{
    // Only show if connected and target is not already a visible pair
    if (!_isConnected) return;
    if (_pairManager.IsPlayerHashVisible(targetHash)) return;
    
    // Add the "Send Pair Request" menu item
    AddBlindPairContextMenu(args, target.TargetContentId);
}

Step 2: Client Prepares the Request

// ApiController.Functions.Users.cs
public async Task UserRequestBlindPair(string targetCharacterHash)
{
    var myCharacterHash = await _dalamudUtil.GetPlayerNameHashedAsync();
    
    // Generate random index in middle 40%
    var random = new Random();
    var partialHashIndex = random.Next(BlindPairConstants.HashMinIndex, 
                                        BlindPairConstants.HashMaxIndex + 1);
    
    // Extract 6-character partial hash
    var partialHash = targetCharacterHash.Substring(partialHashIndex, 
                                                     BlindPairConstants.PartialHashLength);
    
    // Create both DTOs
    var partialDto = new PartialBlindPairRequestDto
    {
        PartialCharacterHash = partialHash,
        PartialHashIndex = partialHashIndex
    };
    
    var fullDto = new FullBlindPairRequestDto
    {
        RequesterCharacterHash = myCharacterHash,
        TargetCharacterHash = targetCharacterHash
    };
    
    // Sign both with client's private key
    var partialRequest = await _cryptoService.GetSignedRequest(LodestoneId, partialDto);
    var fullRequest = await _cryptoService.GetSignedRequest(LodestoneId, fullDto);
    
    // Send via SignalR
    await _mareHub.SendAsync("UserRequestBlindPair", partialRequest, fullRequest);
}

Step 3: Home Server Processes Request

┌─────────────────────────────────────────────────────────────────┐
│  HOME SERVER (where requester is registered)                    │
│                                                                  │
│  1. Validate partial hash (6 chars, index 19-38)                │
│  2. Validate full hash (both hashes present)                    │
│  3. Save PendingBlindPairRequest to database                    │
│  4. Check if target is online LOCALLY first                     │
│  5. If not found locally → broadcast to federation              │
└─────────────────────────────────────────────────────────────────┘
// MareHubCommandsService.BlindPair.cs
public async Task<FederatedCommandResponse<BlindPairResult>> RequestBlindPairAsync(...)
{
    // Save pending request
    var pendingRequest = new PendingBlindPairRequest
    {
        RequesterUID = requesterUID,
        RequesterCharacterHash = fullRequest.Payload.RequesterCharacterHash,
        TargetCharacterHash = fullRequest.Payload.TargetCharacterHash,
        CreatedAt = DateTime.UtcNow
    };
    await DbContext.PendingBlindPairRequests.AddAsync(pendingRequest);
    
    // Check local users first
    var localResult = await CheckLocalOnlineUsersForBlindPair(...);
    if (localResult != null) return localResult;
    
    // Not found locally - broadcast to federation
    return await BroadcastPartialPairLookup(...);
}

Step 4: Federation Broadcast (Partial Hash Only)

┌─────────────────────────────────────────────────────────────────┐
│                     FEDERATION NETWORK                           │
│                                                                  │
│  Home Server sends ONLY the partial hash to ALL federated        │
│  servers:                                                        │
│                                                                  │
│    RequestPartialPairCommand {                                   │
│      PartialCharacterHash: "E2F4C6"    // Just 6 chars!         │
│      PartialHashIndex: 25                                        │
│    }                                                             │
│                                                                  │
│  ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐  │
│  │ Server A │    │ Server B │    │ Server C │    │ Server D │  │
│  │ No match │    │ No match │    │ MATCH!   │    │ No match │  │
│  │          │    │          │    │          │    │          │  │
│  └──────────┘    └──────────┘    └──────────┘    └──────────┘  │
└─────────────────────────────────────────────────────────────────┘
// Receiving server checks its online users
public async Task<FederatedCommandResponse<PartialHashLookupResultDto>> 
    ProcessRemotePartialPairLookup(RequestPartialPairCommand command)
{
    var matchingHashes = new List<string>();
    
    // Check all online users on this server
    var onlineUserKeys = await _redis.SearchKeysAsync("UID:*");
    
    foreach (var key in onlineUserKeys)
    {
        var charaIdent = await _redis.GetAsync<string>(key);
        
        // Extract same 6 characters at same position
        var extractedPartial = charaIdent.Substring(partialIndex, 6);
        
        if (extractedPartial == partialHash)
        {
            // Partial match! Return the FULL hash
            matchingHashes.Add(charaIdent);
        }
    }
    
    return new PartialHashLookupResultDto { MatchingHashes = matchingHashes };
}

Step 5: Full Request to Matching Server

Only after a partial match is confirmed:

┌─────────────────────────────────────────────────────────────────┐
│  MATCHING SERVER (Server C)                                      │
│                                                                  │
│  Home server verified partial match, now sends full request:    │
│                                                                  │
│    RequestFullPairCommand {                                      │
│      RequesterCharacterHash: "A3F7E2C9D4B6..."  // Full 64      │
│      TargetCharacterHash: "B1C2D3E4F5A6..."     // Full 64      │
│    }                                                             │
│                                                                  │
│  Server C saves pending request and checks for MUTUAL request   │
└─────────────────────────────────────────────────────────────────┘

Step 6: Mutual Match Detection

// Check if target already sent a request to requester
var mutualRequest = await DbContext.PendingBlindPairRequests
    .FirstOrDefaultAsync(p =>
        p.RequesterCharacterHash == targetHash &&  // Their hash
        p.TargetCharacterHash == requesterHash);   // My hash

if (mutualRequest != null)
{
    // MUTUAL MATCH! Create full bidirectional pairing
    await CreateBlindPairClientPairs(...);
    
    return new BlindPairResult 
    { 
        MutualPairCreated = true,
        MatchedUser = localUser.ToCommandUserData()
    };
}

Privacy and Anonymity Analysis

What Information is Exposed?

RecipientInformation ReceivedCan Identify Target?
Home ServerFull hash of both usersYes (if target online locally)
Non-matching Servers6-character partial hashNo
Matching ServerFull hash (only after partial match)Yes (completes pairing)

Entropy Analysis

SHA256 produces 256 bits of entropy. The 64-character hex string has 4 bits per character.

Partial hash (6 chars × 4 bits) = 24 bits of information
Full hash (64 chars × 4 bits) = 256 bits of information

Partial hash reveals: 24/256 = 9.4% of the hash

False positives are harmless: Even if a partial hash matches the wrong user, the full hash verification will fail, and no pairing will occur.

Why Random Index Selection?

The partial hash index is randomly selected from positions 19-38 (middle 40% of the hash) for each request:

  1. Prevents pattern analysis: An observer can't build a lookup table mapping partial hashes to users
  2. Middle portion: Avoids predictable first/last characters that might have lower entropy
  3. Fresh randomness per request: Each request uses a different 6-character window

What a Malicious Federated Server Can Learn

A malicious server in the federation can:

  • ✅ See partial hashes broadcast to them
  • ✅ Know that someone is trying to pair with someone matching that partial
  • Cannot determine the full identity of the target
  • Cannot determine who sent the request (UID not included in partial broadcast)
  • Cannot link multiple requests together (random index each time)

Data Retention

public class PendingBlindPairRequest
{
    public string RequesterUID { get; set; }
    public string? RequesterServerId { get; set; }
    public string RequesterCharacterHash { get; set; }
    public string TargetCharacterHash { get; set; }
    public DateTime CreatedAt { get; set; }
}

Pending requests are stored until:

  1. A mutual match occurs (both requests are deleted)
  2. A configured expiration period passes (cleanup job)

Sequence Diagram

┌──────────┐     ┌─────────────┐     ┌─────────────────┐     ┌─────────────────┐
│ Client A │     │ Home Server │     │ Other Fed Servers│    │ Target's Server │
└────┬─────┘     └──────┬──────┘     └────────┬─────────┘    └────────┬────────┘
     │                   │                     │                       │
     │ Right-click target│                     │                       │
     │ (get ContentID)   │                     │                       │
     │                   │                     │                       │
     │ Compute full hash │                     │                       │
     │ Select random idx │                     │                       │
     │ Extract 6-char    │                     │                       │
     │                   │                     │                       │
     │ UserRequestBlind- │                     │                       │
     │ Pair(partial,full)│                     │                       │
     │──────────────────>│                     │                       │
     │                   │                     │                       │
     │                   │ Save pending request│                       │
     │                   │ Check local users   │                       │
     │                   │ (not found)         │                       │
     │                   │                     │                       │
     │                   │ Broadcast PARTIAL   │                       │
     │                   │ to all servers      │                       │
     │                   │────────────────────>│                       │
     │                   │                     │                       │
     │                   │                     │ Check online users    │
     │                   │                     │ (no match)            │
     │                   │                     │                       │
     │                   │                     │──────────────────────>│
     │                   │                     │                       │
     │                   │                     │        Check online   │
     │                   │                     │        users          │
     │                   │                     │        MATCH FOUND!   │
     │                   │                     │                       │
     │                   │<───────────────────────────────────────────│
     │                   │          Return matching full hash          │
     │                   │                     │                       │
     │                   │ Verify full match   │                       │
     │                   │ Send FULL request   │                       │
     │                   │────────────────────────────────────────────>│
     │                   │                     │                       │
     │                   │                     │        Save pending   │
     │                   │                     │        Check mutual   │
     │                   │                     │        (if found:     │
     │                   │                     │         create pair)  │
     │                   │                     │                       │
     │                   │<───────────────────────────────────────────│
     │                   │          BlindPairResult                    │
     │                   │                     │                       │
     │ Notification:     │                     │                       │
     │ "Request sent" or │                     │                       │
     │ "Paired!"         │                     │                       │
     │<──────────────────│                     │                       │
     │                   │                     │                       │

Security Considerations

Rate Limiting

To prevent brute-force hash enumeration:

  • Servers rate limit blind pair requests per user
  • Duplicate pending requests for the same target are rejected
var existing = await DbContext.PendingBlindPairRequests.AnyAsync(
    x => x.RequesterUID == requesterUID && 
         x.TargetCharacterHash == fullRequest.Payload.TargetCharacterHash);

if (existing)
{
    return FederatedCommandResponse<BlindPairResult>.CreateError(
        "A pending pair request already exists for this target character");
}

Self-Pairing Prevention

if (string.Equals(myCharacterHash, targetCharacterHash, StringComparison.OrdinalIgnoreCase))
{
    Logger.LogWarning("Cannot blind pair with yourself");
    return;
}

Summary

The Blind Pairing system provides:

  1. Convenience: Pair with nearby players without exchanging UIDs
  2. Privacy: Target identity hidden until mutual consent
  3. Federation: Works across the entire server network
  4. Security: Cryptographically signed, rate-limited requests

The two-phase protocol (partial then full hash) minimizes information leakage while enabling efficient lookup across a distributed system.

Previous
Zero-Trust Implementation