Class ManifestSigning
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
PrivateKeySeedLength
Ed25519 private keys (seeds) are exactly 32 bytes.
public const int PrivateKeySeedLength = 32
Field Value
PublicKeyLength
Ed25519 public keys are exactly 32 bytes.
public const int PublicKeyLength = 32
Field Value
SignatureFieldName
The field name carrying the signature.
public const string SignatureFieldName = "signature"
Field Value
SignatureLength
Ed25519 signatures are exactly 64 bytes.
public const int SignatureLength = 64
Field Value
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
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
privateSeedbyte[]
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
bodystringThe document body WITHOUT the signature field and WITHOUT the enclosing braces — exactly what BuildBody(JsonObject) produces.
privateSeedbyte[]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
Returns
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
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
privateKeyBase64stringBase64 of a 32-byte seed or a 64-byte seed||public blob.
privateSeedbyte[]The 32-byte seed on success.
errorstringWhy it failed. Never contains any part of the key material.
Returns
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)