The Standard

Epistula V2 is a protocol for secure message signing and verification. It uses a set of headers to ensure the authenticity and integrity of the request body. The core components are the request headers and the body.

Headers

{
  "Epistula-Version": "2",
  "Epistula-Timestamp": str,
  "Epistula-Uuid": str,
  "Epistula-Signed-By": str,
  "Epistula-Request-Signature": str,
  "Epistula-Signed-For": str(optional),
  "Epistula-Secret-Signature-0": str(optional),
  "Epistula-Secret-Signature-1": str(optional),
  "Epistula-Secret-Signature-2": str(optional)
}

Headers

Epistula-Version

required

The version of the Epistula protocol being used. For this specification, it's "2".

Epistula-Timestamp and Epistula-Uuid

required

To protect against MITM replay attacks, Epistula uses UNIX millisecond timestamps and request UUIDs with a 5 second delta. It is up to the receiver to properly handle the storage of UUIDs and detect if a message has been replayed within the allowed delta.

Epistula-Signed-By

required

The ss58_address of the signer, used to verify the request signature.

Epistula-Signed-For

optional

The ss58_address of the intended receiver. This helps prevent relay attacks.

Epistula-Request-Signature

required

A hex-encoded signature created using the signer's ed25519 key (typically the user's hotkey in Bittensor) via the schnorrkel algorithm. The signature is generated from period joined concatenated string containing:

  1. SHA256 hash of the body as bytes
  2. UUID of the request
  3. Timestamp
  4. Signed-for address (if present, otherwise an empty string)

An example of a request signature on a json body

"Epistula-Request-Signature": "0x" + hotkey.sign(f'{json.dumps(body)}.{uuid}.{timestamp}.{signed_for}').hex(),

Epistula-Secret-Signature-0, Epistula-Secret-Signature-1, Epistula-Secret-Signature-2

optional

These three signatures are used as a sliding window of signatures that can checked purely from headers, and cached across requests. The signatures are crafted by taking the current time in milliseconds, rounding to the nearest 10,000th combined with the receivers hotkey.

epochNonce = round(time.time() * 1000)
epochInterval = ceil(epochNonce / 1e4) * 1e4
secretSig0 = hotkey.sign(str(epochInterval-1) + '.' + signed_for)
secretSig1 = hotkey.sign(str(epochInterval)   + '.' + signed_for)
secretSig2 = hotkey.sign(str(epochInterval+1) + '.' + signed_for)

Miners can optionally use these to protect against ddos attacks by implemting the following process:

When no signatures are cached, or all signatures are stale (older than current epoch interval - 2) only check Epistula-Secret-Signature-1. When a valid request is found, cache all 3 signatures, each with a stale time of 1.6 min * x, where x is the signature index + 1. If you get a request where any signature is cached and not stale, re-cache with the new requests 3 signatures. If all signatures are stale, act as if there are no signatures.

As long as the endpoint is getting valid requests from the same sender once every interval [1.6 min] * 3, the cache should always be populated.