Table of Contents

Class ManifestSigning

Namespace
FishMMO.WebServers.Signing
Assembly
Patcher.dll

Ed25519 signing of JSON manifests, in the exact canonical form the FishMMO client verifies. Deliberately free of ASP.NET, DI and logging dependencies so the shipping server, the operator's offline signing tool and the round-trip test harness all compile the same source file rather than three transcriptions of the same rules.

public static class ManifestSigning
Inheritance
ManifestSigning
Inherited Members

Remarks

The canonical form. The signed message is the JSON document with the value of its "signature" field replaced by an empty string — the field itself stays where it is, rewritten to the exact text BlankSignatureField. Nothing is appended. The reference implementation is FishMMO.Client.Security.Ed25519ManifestVerifier.BuildCanonicalSignedMessage; this file mirrors it, and Tools/ManifestSigning.Tests proves the mirror by compiling the *actual client verifier source* and round-tripping documents produced here through it.

Why textual and not a re-serialisation. The verifier operates on the exact bytes it received. If it re-serialised instead, signer and verifier would have to agree on key order, number formatting, spacing and string escaping, and any disagreement is either a good manifest that will not verify or — much worse — a field an attacker can alter without disturbing what actually gets hashed. Working on the received bytes removes that whole class of question, at the cost of requiring the signer to emit the document itself rather than hand an object to a serialiser. ManifestJsonWriter is that emitter.

The signature is not part of its own message. Both this file and the client verifier used to end the canonical message with the base64 signature appended. That is unsatisfiable: it asks for sig = Sign(sk, stripped || base64(sig)), a fixed point of a hash-driven function over 64 bytes, which Ed25519 makes cost about 2^256 to find because R is derived from H(prefix || M). It went unnoticed for as long as it did precisely because nothing in the tree had ever signed a manifest, so the verifier was never handed a document that was supposed to pass.

Fields

BlankSignatureField

The exact text the signature field is normalised to in the canonical message. Must stay byte-identical to FishMMO.Client.Security.Ed25519ManifestVerifier.BlankSignatureField.

public const string BlankSignatureField = "\"signature\": \"\""

Field Value

string

PrivateKeySeedLength

Ed25519 private keys (seeds) are exactly 32 bytes.

public const int PrivateKeySeedLength = 32

Field Value

int

PublicKeyLength

Ed25519 public keys are exactly 32 bytes.

public const int PublicKeyLength = 32

Field Value

int

SignatureFieldName

The field name carrying the signature.

public const string SignatureFieldName = "signature"

Field Value

string

SignatureLength

Ed25519 signatures are exactly 64 bytes.

public const int SignatureLength = 64

Field Value

int

Methods

BuildCanonicalSignedMessage(string, string)

The canonical-message construction, mirroring the client verifier exactly: locate the signature field carrying signatureBase64 and rewrite it to BlankSignatureField.

public static string? BuildCanonicalSignedMessage(string json, string signatureBase64)

Parameters

json string
signatureBase64 string

Returns

string

The canonical message, or null when the field could not be located.

Remarks

The server does not need this to produce a signature — SignDocument(string, byte[]) builds the blanked document first and substitutes afterwards, so there is nothing to search for. It exists so the server can self-check every document it is about to emit by running the client's own algorithm over it (see SignDocument(string, byte[])'s post-condition), turning any future drift between the two files into a refusal here rather than a manifest no player can verify.

DerivePublicKey(byte[])

Derives the public key for a private seed.

public static byte[] DerivePublicKey(byte[] privateSeed)

Parameters

privateSeed byte[]

Returns

byte[]

GenerateKeyPair()

Generates a fresh Ed25519 keypair.

public static (byte[] PrivateSeed, byte[] PublicKey) GenerateKeyPair()

Returns

(byte[] PrivateSeed, byte[] PublicKey)

The 32-byte private seed and the 32-byte public key.

SignDocument(string, byte[])

Signs a manifest and returns the finished JSON document, signature field included.

public static string SignDocument(string body, byte[] privateSeed)

Parameters

body string

The document body WITHOUT the signature field and WITHOUT the enclosing braces — exactly what BuildBody(JsonObject) produces.

privateSeed byte[]

32-byte Ed25519 seed.

Returns

string

The complete JSON document to send.

Remarks

The signature field is always emitted LAST. The verifier uses LastIndexOf to find it, so putting it last means the search cannot be steered by a value elsewhere in the document that happens to reproduce the same 88 base64 characters — an 88-character collision is not a real risk, but "last" costs nothing and removes the question.

Post-condition, enforced. Before returning, the client's own locate-and-blank algorithm is run over the finished document and the result compared to the bytes that were actually signed. If they differ, this throws instead of returning. That converts any future divergence between this file and Ed25519ManifestVerifier — a change to the placeholder spacing, an escaping bug in the writer, a field value that contrives to contain the signature — into a loud server-side failure, rather than a document that every client in the field silently refuses. It costs one string comparison over a payload of a few hundred bytes.

SignJsonObject(string, byte[])

Signs an arbitrary JSON object supplied as text (the offline path used by the signing tool). Any existing signature field is discarded and re-emitted last.

public static string SignJsonObject(string json, byte[] privateSeed)

Parameters

json string
privateSeed byte[]

Returns

string

Remarks

Unlike the server hot path this DOES re-serialise, because it is handed a document somebody else wrote and there is no other way to guarantee the emitted spacing matches the canonical form. The output — not the input — is the signed artifact, and the tool says so.

SignMessage(byte[], string)

Signs message (UTF-8) with the given private seed.

public static byte[] SignMessage(byte[] privateSeed, string message)

Parameters

privateSeed byte[]
message string

Returns

byte[]

TryDecodePrivateKey(string?, out byte[]?, out string?)

Decodes a base64 private key.

public static bool TryDecodePrivateKey(string? privateKeyBase64, out byte[]? privateSeed, out string? error)

Parameters

privateKeyBase64 string

Base64 of a 32-byte seed or a 64-byte seed||public blob.

privateSeed byte[]

The 32-byte seed on success.

error string

Why it failed. Never contains any part of the key material.

Returns

bool

Remarks

Two encodings are accepted because the two tools an operator is likely to reach for disagree: a raw 32-byte seed (what keygen here emits, and what BouncyCastle and most .NET code mean by "private key"), and the 64-byte libsodium/OpenSSH form which is seed || publicKey. When 64 bytes are supplied the trailing half is checked against the key actually derived from the seed, so a truncated or spliced key is rejected here rather than silently producing signatures nobody can verify.

VerifyMessage(byte[], string, string)

Verifies a base64 signature over message. Present so the tool and the server can self-check without depending on the Unity client assembly.

public static bool VerifyMessage(byte[] publicKey, string message, string signatureBase64)

Parameters

publicKey byte[]
message string
signatureBase64 string

Returns

bool