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 signatureSignature
: 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 withiam=
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 withiam=
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 theContent-Digest
headertreasury
is the value of theTreasury
headersignature-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 ofecdsa-p256-sha256
,ecdsa-k256-sha256
, ored25519
.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 signaturenonce
is a 64-bit unsigned integer (quoted)tag
must always be set: to the empty string to initiate a new request, or set toapprove:<operation-id>
orcancel:<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()