Class Ed25519ManifestVerifier
Verifies an Ed25519 signature carried inside a JSON document, against a public key embedded in the client at build time.
public static class Ed25519ManifestVerifier
- Inheritance
-
Ed25519ManifestVerifier
- Inherited Members
Remarks
Extracted from FishMMO.Client.Security.ApiPinUpdateSidecar, which had the only implementation. It
is shared now because a second manifest needs exactly the same treatment: the version
manifest from /latest_version carries the SHA-256 the patch download is checked
against, and was itself unsigned — the project's own TODO, and the finding (S4) that made
the traversal and integrity issues around it reachable in the first place.
What signing buys that TLS does not. The manifest already travels over a pinned TLS connection, so this is not about a passive observer. It is about the fact that everything downstream trusts the manifest completely: the SHA-256 in it is the ONLY integrity check on the patch archive, so whoever writes the manifest chooses which bytes the updater will accept. TLS authenticates the transport; it says nothing about a compromised or misconfigured gateway, a mis-issued certificate, or a CDN edge with a stale/hostile copy. A signature moves the trust anchor from "whoever is answering on this host" to "whoever holds the release key" — which is the property the patch pipeline actually needs, because it is the same property that makes the SHA-256 worth checking.
The canonical form is the document with the value of its "signature" field
replaced by an empty string — specifically, rewritten to the exact text
BlankSignatureField, so the signed bytes do not depend on which spacing the
serialiser used. It is deliberately
textual rather than a re-serialisation: re-serialising means the verifier and the signer
must agree on key order, spacing and escaping, and any disagreement is either a failure
to verify a good manifest or — far worse — a way to alter a field without disturbing what
gets hashed. Operating on the exact bytes received removes that whole class of question.
This is the same construction the pin manifest already used, kept identical so one
signing tool serves both.
Fields
BlankSignatureField
The exact text the signature field is normalised to before signing/verifying.
public const string BlankSignatureField = "\"signature\": \"\""
Field Value
Remarks
A single constant, deliberately. The verifier accepts either ":" or ": "
spacing on the wire but always rewrites the field to THIS form before hashing, so the
signed bytes do not depend on which serialiser produced the document. A signer that
emits the placeholder verbatim, signs, and then substitutes the base64 value into it
reproduces these bytes exactly.
Methods
BuildCanonicalSignedMessage(string, string)
Produces the canonical signed message: the document with the value of its
"signature" field replaced by an empty string.
public static string BuildCanonicalSignedMessage(string json, string signatureBase64)
Parameters
Returns
- string
The canonical message, or null when the signature field could not be located.
Remarks
Returning null on a missing field is a behaviour change from the version this was extracted from, and it is a security fix rather than tidying. That version fell back to signing the raw JSON plus the signature and left a warning in the log — a fallback with an entirely different canonical form, which means a signer could produce a document that verifies down the fallback path while the field the verifier believes it checked is not the field it hashed. A verifier that cannot find what it is verifying has not verified anything; it says so and fails.
The signature is NOT appended to the message, and this is the second correction
carried in here. Both this method and the ApiPinUpdateSidecar version it
came from used to return stripped + signatureBase64 — a message containing the
very signature being verified. Producing one requires solving
sig = Sign(sk, stripped || base64(sig)), i.e. a fixed point of a hash-driven
function over a 64-byte value: Ed25519 derives R from H(prefix || M), so
any change to M re-randomises the entire signature and a fixed point costs
roughly 2^256 work to find. No signer could ever satisfy it, which is why the scheme
survived unnoticed — nothing in the tree has ever signed either manifest, so the
verifier had never been handed a document that was supposed to pass. The signature is
not needed in the message in any case: it is a signature *over* the document, and the
blanked field already pins the field's presence and position.
Both spacings are tried because JSON serialisers differ on whether a colon is followed by a space, and the document is compared as received rather than reformatted. The LAST occurrence is used: the signature value is 88 base64 characters and will not appear elsewhere by accident, but if a document did contrive to repeat it, the field itself is conventionally last.
TryDecodePublicKey(string, out byte[])
Decodes and validates a base64 Ed25519 public key.
public static bool TryDecodePublicKey(string publicKeyBase64, out byte[] publicKey)
Parameters
publicKeyBase64stringThe embedded key, or null/empty when not configured.
publicKeybyte[]The 32-byte key on success.
Returns
- bool
True when a usable key was configured.
Verify(byte[], string, string)
Verifies signatureBase64 over fullJson.
public static bool Verify(byte[] publicKey, string fullJson, string signatureBase64)
Parameters
publicKeybyte[]32-byte Ed25519 public key.
fullJsonstringThe raw JSON document exactly as received, signature field included.
signatureBase64stringThe base64 signature value taken from that document.
Returns
- bool
True only when the signature verifies. Every other outcome is false.
Remarks
Fails closed by construction: there is no path through this method that returns true
without VerifySignature having returned true. Malformed input, a decode
failure and a cryptographic mismatch are all simply "false" — the caller must not be
able to tell them apart and act differently, because the correct action is identical.