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
- Mutual Consent Required: A pairing only occurs when BOTH players have sent pair requests to each other
- Privacy Preserving: Servers that don't have a matching user learn almost nothing about the request
- 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:
- The client generates a random index between 19-38 (the middle 40% of the 64-character hash)
- Extracts 6 characters starting at that random position
- 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?
| Recipient | Information Received | Can Identify Target? |
|---|---|---|
| Home Server | Full hash of both users | Yes (if target online locally) |
| Non-matching Servers | 6-character partial hash | No |
| Matching Server | Full 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:
- Prevents pattern analysis: An observer can't build a lookup table mapping partial hashes to users
- Middle portion: Avoids predictable first/last characters that might have lower entropy
- 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:
- A mutual match occurs (both requests are deleted)
- 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:
- Convenience: Pair with nearby players without exchanging UIDs
- Privacy: Target identity hidden until mutual consent
- Federation: Works across the entire server network
- 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.