Venue Shell Discovery

Overview

Venue Shell Discovery is a privacy-preserving mechanism that allows players to automatically find and join syncshells tied to housing locations (venues) without revealing their exact location to the federated server network. This document explains the complete flow from client to server, the cryptographic constructs involved, and the privacy guarantees provided.

What is a Venue Shell?

A Venue Shell is a special syncshell (IsVenueShell = true) associated with a specific housing plot. When a player enters a venue building, HonseFarm can automatically discover and join the venue's shell — enabling real-time character syncing with other visitors — and leave it when the player exits.

Key Properties

  1. Location-Based: Venue shells are bound to a specific World + District + Ward + Plot combination
  2. Automatic Join/Leave: Players join when entering a venue building and leave when exiting
  3. Privacy Preserving: The federated network never learns which plot the player is visiting
  4. No Credentials Required: Venue shells use password-free joins, unlike regular syncshells

The Location Hash

What It Is

The Location Hash is a SHA256 hash of the player's current housing plot coordinates. The hash input uses pipe-separated components to prevent ambiguity between fields.

"{World}|{District}|{Ward}|{Plot}" → SHA256 → 64-character hexadecimal string

Generation

Client-side (VenueShellManager.cs):

var worldName = _dalamudUtil.WorldData.Value.TryGetValue((ushort)location.ServerId, out var name)
    ? name : location.ServerId.ToString();
var locationString = $"{worldName}|{location.DivisionId}|{location.WardId}|{location.HouseId}";
var newHash = Crypto.GetHash256(locationString);

Server-side (when a venue shell is created, HonseHub.GroupsLocal.cs):

var locationString = $"{venueShellCreate.World}|{venueShellCreate.District}|{venueShellCreate.Ward}|{venueShellCreate.Plot}";
newGroup.VenueLocationHash = StringUtils.Sha256String(locationString);

Both sides produce identical hashes for the same plot because they use the same input format and SHA256.

Why Pipe Separators Matter

Without separators, Ward=1, Plot=23 and Ward=12, Plot=3 would both produce the input "...123" and collide. The | delimiter ensures each field boundary is unambiguous:

"Balmung|Lavender Beds|1|23"  ≠  "Balmung|Lavender Beds|12|3"

Example

Location:   Balmung, Lavender Beds, Ward 1, Plot 23
Input:      "Balmung|Lavender Beds|1|23"
SHA256:     A3F7E2C9D4B6A8E1F0C3D5B7A9E2F4C6D8B1A3E5F7C9D2B4A6E8F0C1D3B5A7E9
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
            64 characters (256 bits in hexadecimal)

The Two-Phase Lookup Protocol

To preserve privacy, venue shell discovery uses the same two-phase partial-hash protocol as blind pairing, sharing the constants defined in FederationLookupConstants:

public static class FederationLookupConstants
{
    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
}

Phase 1: Partial Hash Broadcast

Only a small portion of the location hash is sent to federated servers.

How it works:

  1. The client generates a random index between 19 and 32 (HashMaxIndex − PartialHashLength)
  2. Extracts 6 characters starting at that random position
  3. Sends only this 6-character partial hash and the index to the home server
  4. The home server forwards the 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 Verification (Home Server Only)

The full location hash never leaves the home server's request context. Remote servers return candidate matches with their full VenueLocationHash attached. The home server then filters:

var verifiedFederated = federatedMatches
    .Where(m => string.Equals(m.VenueLocationHash, fullHash, StringComparison.OrdinalIgnoreCase))
    .Select(m => m with { VenueLocationHash = null })
    .ToList();

The VenueLocationHash is stripped from results before being sent to the client — the client never sees other venues' hashes.

Complete Flow: Client to Server(s)

Step 1: Player Enters a Venue Building

Step 2: Client Sends Partial + Full Request

var partialDto = new PartialVenueShellLookupRequestDto
{
    PartialLocationHash = partialHash,   // 6 chars
    PartialHashIndex = partialIndex      // 19-32
};

var fullDto = new FullVenueShellLookupRequestDto
{
    FullLocationHash = fullHash          // 64 chars
};

Both are signed with the client's private key and sent via SignalR to the home server.

Step 3: Home Server Processes the Request

var localMatches = await CheckLocalVenueShells(fullHash);

var cached = _venueShellLookupCacheService.TryGetMatches(fullHash);
if (cached != null)
{
    allMatches.AddRange(cached.Matches);
    skipServerIds.UnionWith(cached.QueriedServerIds);
}

var (federatedMatches, queriedServerIds) = await BroadcastPartialVenueShellLookup(
    originatingUser, partialRequest, skipServerIds);

var verifiedFederated = federatedMatches
    .Where(m => string.Equals(m.VenueLocationHash, fullHash, StringComparison.OrdinalIgnoreCase))
    .Select(m => m with { VenueLocationHash = null })
    .ToList();

Step 4: Federation Broadcast (Partial Hash Only)

Each remote server checks its local venue shells:

private async Task<List<VenueShellMatchDto>> CheckLocalVenueShellsByPartialHash(
    string partialHash, int partialIndex)
{
    var venueShells = await DbContext.Groups
        .Where(g => g.IsVenueShell && g.InvitesEnabled && g.VenueLocationHash != null)
        .Select(g => new { g.GID, g.Alias, g.VenueLocationHash, ... })
        .Where(g => g.MemberCount < _maxGroupUserCount)
        .ToListAsync();

    foreach (var shell in venueShells)
    {
        var extractedPartial = shell.VenueLocationHash
            .Substring(partialIndex, FederationLookupConstants.PartialHashLength);

        if (string.Equals(extractedPartial, partialHash, StringComparison.OrdinalIgnoreCase))
            matches.Add(/* match with full VenueLocationHash included */);
    }

    return matches;
}

Matching servers return their results including the full VenueLocationHash so the home server can verify. Non-matching servers return empty results and learn nothing useful.

Step 5: Client Receives Results

The client filters out shells it has already joined, then either:

  • Auto-joins if VenueShellsAutoJoin is enabled
  • Shows a prompt for manual confirmation
  • Shows the intro dialog if this is the player's first encounter
if (_configService.Current.VenueShellsAutoJoin)
{
    foreach (var match in filteredMatches)
        await JoinVenueShell(match);
}
else
{
    Mediator.Publish(new VenueShellDiscoveryResultMessage(filteredResult));
}

Step 6: Player Leaves the Venue

When the player zones out of a housing plot (HouseId == 0), venue shells are automatically left. Recently-joined shells are cached for 15 minutes so re-entering the same plot triggers an instant rejoin without a new discovery request.

Privacy and Anonymity Analysis

What Information is Exposed?

RecipientInformation ReceivedCan Determine Location?
Home ServerFull location hashOnly by brute-forcing all World/District/Ward/Plot combinations (feasible — see below)
Non-matching Federated Servers6-character partial hash + indexNo — insufficient entropy, random window
Matching Federated Server6-character partial hash + indexNo — only knows a partial match exists among its own venues
ClientGID + Alias + ServerId of matchesOnly the venue shell names, not other venues' hashes

Entropy Analysis of the Partial Hash

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

With 24 bits, there are 16,777,216 possible partial hash values. Given that FFXIV has a limited number of housing plots (on the order of tens of thousands across all worlds), a 6-character partial match is expected to produce very few — often zero — false positives. However, the key point is that false positives are harmless: the home server's full-hash verification rejects them, and the remote server never learns whether its candidate was the actual target.

Why Random Index Selection?

The partial hash index is randomly selected from positions 19-32 for each request:

  1. Prevents correlation: An observer cannot build a lookup table mapping partial hashes to locations because the extraction window changes every time
  2. Middle portion: Avoids the first and last characters of the hash, which could theoretically have slightly biased distributions in some hash implementations
  3. Fresh randomness per request: Even repeat visits to the same plot produce different partial hashes

What a Malicious Federated Server Can Learn

A malicious server participating in the federation can:

  • ✅ See the partial hash and index broadcast to it
  • ✅ Know that someone on another server is looking for a venue at a location that partially matches
  • ✅ Enumerate its own venue shells to find which (if any) partially match
  • Cannot determine the requester's full location hash
  • Cannot determine who sent the request (UID is not included in the partial broadcast payload)
  • Cannot correlate multiple requests from the same user (random index each time)
  • Cannot distinguish a real lookup from a false positive

What the Home Server Can Learn

The home server receives the full location hash. Since FFXIV housing plots are a finite, enumerable set (World x District x Ward x Plot), a malicious home server could pre-compute a rainbow table mapping every possible plot to its hash. This is an inherent limitation of hashing a small input space.

Mitigations:

  • The home server is the player's own trusted server — it already knows the player's identity
  • The home server does not learn anything it couldn't already infer from the player's connection
  • The full hash is never forwarded to other servers; only the partial hash is broadcast

The Full Hash Never Leaves the Home Server

This is the critical privacy guarantee:

// Home server verifies federated results locally
var verifiedFederated = federatedMatches
    .Where(m => string.Equals(m.VenueLocationHash, fullHash, ...))
    .Select(m => m with { VenueLocationHash = null })  // Strip before returning to client
    .ToList();
  1. The client sends the full hash to its home server only
  2. The home server uses it for local DB lookups and to verify federated results
  3. Remote servers only ever see the 6-character partial hash
  4. The client only receives GID, Alias, and ServerId — never other venues' location hashes

Caching

Server-Side: VenueShellLookupCacheService

The home server caches verified federated results for 5 minutes to avoid redundant broadcasts:

  • Key: Full location hash
  • Value: Verified matches + set of already-queried server IDs
  • TTL: 5 minutes
  • Thread Safety: Immutable-style AddOrUpdate with Interlocked.CompareExchange cleanup guard
  • Invalidation: Cache is cleared when venue shells are created or deleted

On subsequent lookups for the same location, servers that already responded are skipped:

var cached = _venueShellLookupCacheService.TryGetMatches(fullHash);
if (cached != null)
{
    allMatches.AddRange(cached.Matches);
    skipServerIds.UnionWith(cached.QueriedServerIds);
}

Client-Side: Rejoin Cache

The client caches recently-left venue shells for 15 minutes. If the player re-enters the same plot within that window, they rejoin immediately without issuing a new discovery request:

if (TryRejoinFromCache(newHash))
    return;

Sequence Diagram

Security Considerations

Only Open Venue Shells Are Discoverable

Both local and federated lookups filter on InvitesEnabled:

.Where(g => g.IsVenueShell && g.InvitesEnabled && ...)

A venue shell owner can disable discovery at any time by toggling invites off.

Capacity Limits

Venue shells that have reached the maximum member count are excluded from results:

.Where(g => g.MemberCount < _maxGroupUserCount)

Venue Shell Joins Bypass Group Limits

Venue shell joins do not count against the player's maximum joined-groups limit, preventing a situation where a player can't enter a venue because they're in too many regular syncshells:

if (joinedGroups >= _maxJoinedGroupsByUser && !group.IsVenueShell)
    return;

Signed Requests

Both the partial and full lookup DTOs are wrapped in SignedRequest<T>, signed with the client's Ed25519 private key. The server validates the signature before processing:

var partialRequest = await _cryptoService.GetSignedRequest(LodestoneId, partialDto);
var fullRequest = await _cryptoService.GetSignedRequest(LodestoneId, fullDto);

This prevents request forgery and replay attacks.

Duplicate Suppression

The client tracks _lastSentLocationHash and skips discovery if the hash hasn't changed, preventing redundant network traffic from rapid zone transitions.

Summary

Venue Shell Discovery provides:

  1. Convenience: Automatically join venue syncshells by walking into a building
  2. Privacy: Remote servers see only 6 random characters of a location hash — not enough to determine where you are
  3. Federation: Works across the entire federated server network
  4. Verification: Home server validates all federated results against the full hash before returning them
  5. Efficiency: Server-side 5-minute cache and client-side 15-minute rejoin cache minimize redundant lookups

The two-phase protocol (partial then full hash verification) ensures that no federated server — other than the player's own home server — can learn the player's exact location. The home server, which the player already trusts with their identity, is the only entity that ever handles the full location hash.