Skip to main content

Treasury Authentication: Signing Requests

Requests to the Treasury API require HTTP Message Signatures (RFC 9421).

Specifically, this requires setting four headers, after the underlying REST request is constructed.

The simple ones are:

  • Content-Digest: This should contain the SHA256 digest of the (JSON) payload of the request.
  • Treasury: This contains the treasury ID the request is directed to.

The ones needing further explanation are:

  • Signature-Input: This specifies which components of the request are covered by the signature, and adds parameters to the signature
  • Signature: This is the actual signature.

Since there are multiple ways to send equivalent HTTP requests, the construction starts by creating a normalized ASCII byte string corresponding to the components and parameters that are to be signed. This normalized string is called the "signature base". This is what is actually signed.

RFC 9421 makes use of Structured Field Values for HTTP (RFC 8941) syntax to encode complex-ish data structures as HTTP header values. It is not necessary to understand this RFC, as HTTP signatures can be constructed using "format strings".

Example

To start with an example: The signature base (walkthrough below)

"@method": POST
"@path": /v1/chains/SOL/addresses
"@query": ?
content-digest: sha-256=:AvZm5hFnTMn7B3Q8VGQHEXxCdmaezAnN/dQJSKNgJ6c=:
treasury: Xwdn5Z7SiAsPyYTvHJmWMt
"@signature-params": ("@method" "@path" "@query" "content-digest" "treasury");alg="ecdsa-k256-sha256";created=1716327104;keyid="02e93b36f9a686cbb6c1373c89ad9ab78784b945be8031fa713d3b2c3cadceae99";nonce="4723994223921";tag=""

together with an HTTP request with body {"variant":"internal"} and headers

Content-Digest: sha-256=:AvZm5hFnTMn7B3Q8VGQHEXxCdmaezAnN/dQJSKNgJ6c=:
Treasury: Xwdn5Z7SiAsPyYTvHJmWMt
Signature-Input: iam=("@method" "@path" "@query" "content-digest" "treasury");alg="ecdsa-k256-sha256";created=1716327104;keyid="02e93b36f9a686cbb6c1373c89ad9ab78784b945be8031fa713d3b2c3cadceae99";nonce="4723994223921";tag=""
Signature: iam=:0dtwy0s6rBljctY2xQUGleV4AcIWNg6W6BSjq/E1evxI/7C80JKlg4AuwuXAhiuICgH6/TMsn7TOftpceV0k7w==:

corresponds to a valid signed HTTP message for treasury Xwdn5Z7SiAsPyYTvHJmWMt.

  • Signature-Input starts with iam= followed by the value of the key "@signature-params" in the last line of the signature base (explained below). It allows the recipient to reconstruct the signature base from the received HTTP request.
  • Signature starts with iam= followed by a standard Base64 encoding of a signature of the signature base, delimited by colons.

The prefix iam= is a choice of the Treasury API; in principle RFC 9421 supports multiple signatures with varying names on a single HTTP request.

Signature Base

Components of the signature (base)

  • method is the HTTP verb, in upper case.
  • path is the URL path, which corresponds to the endpoint of the API being called. It always starts with /.
  • query is possible query parameters. It always starts with ?.
  • content-digest is the value of the Content-Digest header
  • treasury is the value of the Treasury header
  • signature-params contains first the specification of the components that are covered, and then the four parameters of the signature.

Parameters of the signature:

  • alg is the name of an entry of the HTTP Signature Algorithms Registry, which we have extended with "ecdsa-k256-sha256", "web-authn", and "web-authn-uv". In practice, a programmatic client will use one of ecdsa-p256-sha256, ecdsa-k256-sha256, or ed25519.
  • created is an integer UNIX timestamp with second precision (no quotes)
  • keyid is the public key for the client key that is used to sign the signature
  • nonce is a 64-bit unsigned integer (quoted)
  • tag must always be set: to the empty string to initiate a new request, or set to approve:<operation-id> or cancel:<operation-id> to approve or cancel an operation previously initiated by another user's request. More below.

Python Code

The following Python script can be used to produce valid HTTP signatures for the Treasury API

# std lib
import base64
import hashlib
import secrets
import sys
import time
from urllib.parse import urlparse

# dependencies: `pip install ecdsa; pip install requests`
from ecdsa import SECP256k1 as K256, SigningKey
from requests import get, Request, Session


CONTENT_DIGEST = "sha-256=:{digest}:"

SIGNATURE_PARAMS = """\
("@method" "@path" "@query" "content-digest" "treasury");alg="{alg}";created={created};keyid="{keyid}";nonce="{nonce}";tag="{tag}"\
"""

SIGNATURE_BASE = """\
"@method": {method}
"@path": {path}
"@query": ?{query}
content-digest: {content_digest}
treasury: {treasury}
"@signature-params": {signature_params}
"""

SIGNATURE_INPUT = "iam={signature_params}"
SIGNATURE = "iam=:{sig}:"


# https://stackoverflow.com/a/70478989
def canonical(s_bytes):
n = K256.order
s = int.from_bytes(s_bytes, byteorder="big")
if s > n // 2:
s = n - s
return s.to_bytes(32, byteorder="big")


def sign(request, key, treasury):
request = request.prepare()
url = urlparse(request.url)

created = int(time.time())
keyid = key.get_verifying_key().to_string("compressed").hex()
nonce = secrets.randbelow(1 << 64)
tag = ""

signature_params = SIGNATURE_PARAMS.format(
alg="ecdsa-k256-sha256",
created=created,
keyid=keyid,
nonce=nonce,
tag=tag,
)

signature_input = SIGNATURE_INPUT.format(signature_params=signature_params)

digest = base64.b64encode(hashlib.sha256(request.body).digest()).decode()
content_digest = CONTENT_DIGEST.format(digest=digest)

signature_base = SIGNATURE_BASE.format(
method=request.method.upper(),
path=url.path,
query=url.query,
content_digest=content_digest,
treasury=treasury,
signature_params=signature_params,
)

print(f"{signature_base}")

sig = key.sign(signature_base.encode(), hashfunc=hashlib.sha256)
sig = base64.b64encode(sig[:32] + canonical(sig[32:])).decode()
signature = SIGNATURE.format(sig=sig)

request.headers["Content-Digest"] = content_digest
request.headers["Treasury"] = treasury
request.headers["Signature-Input"] = signature_input
request.headers["Signature"] = signature

return request


def main():
api = sys.argv[1]
# existing client keys can be found in `$(treasury data-directory)/<treasury>/keyring/<key-id>.toml`
key = sys.argv[2]
print(f"{api=}, {key=}")

treasury = get(f"{api}/v1/treasury").json()["name"].split("treasuries/")[1]

key = SigningKey.from_string(bytes.fromhex(key), K256)

payload = {"variant": "internal"}
request = Request(
method="POST",
url=f"{api}/v1/chains/SOL/addresses",
json=payload,
)

signed_request = sign(request, key, treasury)

for key, value in signed_request.headers.items():
print(f"{key}: {value}")

response = Session().send(signed_request)

print(response.text)
assert response.ok


if __name__ == "__main__":
main()