Signing requests
Reads are public. Every write is authorized by a secp256k1 signature over the exact request body: there are no API keys, and the registry never holds your private key.
Who signs what
| Request | Key | Header | Signed bytes |
|---|---|---|---|
| Issuer actions, issuer queries | Asset's current issuer key | Asset-Registry-Signature | The request body (canonical JSON) |
| Icon proposal | Current issuer key | Asset-Registry-Signature | Canonical JSON of the action member |
| Registration, pubkey-bound proof | Contract's initial_issuer_pubkey | Asset-Registry-Signature | Canonical JSON of contract |
| Admin actions and queries | Admin key in actor_pubkey | Asset-Registry-Admin-Signature | The request body (canonical JSON) |
Legacy DELETE | Current issuer key | none (body field) | remove <asset_id> from registry |
How to sign
- Build the JSON object.
- Serialize it as canonical JSON: object keys sorted recursively, no whitespace, UTF-8, no NaN/Infinity. Use integers or strings for numbers: non-integer numbers (
1.5,1e-7) must be formatted exactly as Python does (1e-07), which JavaScript doesn't, so the registry rejects them asnon_canonical_payload. - Sign the bytes in Bitcoin Signed Message format: ECDSA over
SHA256(SHA256(0x18 ‖ "Bitcoin Signed Message:\n" ‖ varint(len) ‖ message)). - Base64-encode the 64-byte compact signature
R‖S(a 65-byte recoverable signature is also accepted). - Send the same bytes as the body, with the signature in the header.
A real signed request (local instance, throwaway key):
POST /v2/assets/5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d/actions
Content-Type: application/json
Asset-Registry-Signature: Fa1R9CBMVbDLBcltLo1IRkF87XT4pUDgE68SzhcJ6XJoJzv4zyt0yE2Ka+PYXFR83Ab6bX6Frv9aEMDeXDjksg==
{"asset_id":"5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d","category_tags":["stablecoin","tokenized"],"mutable_schema_version":1,"nonce":"391bad7c-f582-436e-a026-29eea333bff9","operation":"replace_category_tags","prev_action_hash":"3c19a3bef43921f4ce0dfa865da2c08f153e9285fcef31db4f54b97455f89547","signing_context":"liquid-asset-registry-action-v1","timestamp":"2026-11-03T22:47:34.323000Z"}Use microsecond timestamps, e.g.
2026-10-16T08:59:44.123000Z(JavaScript:new Date().toISOString().replace("Z", "000Z")). Icon uploads fail with millisecond timestamps, and microseconds let anyone re-verify your signatures from the audit log.
Envelope fields
Signed actions carry replay protection. The server rejects anything stale, reused or out of order:
signing_context: fixed per request type, so a signature can't be reused elsewhere.nonce: unique per asset (issuer) or per admin. Reusing it with identical content is a safe retry; with different content it's a 409.timestamp: within ±5 minutes of server time, and not older than your previous action.prev_action_hash(issuer actions): chains each action to the previous one. The action chain.
Signing code
Using TypeScript? The SDK builds and signs requests for you, so you don't need this code.
The helpers below are a reference implementation of the steps above, tested against the registry; every signed endpoint's examples use them. Any secp256k1 ECDSA library works. JavaScript needs only Node.js's built-in crypto. Python uses cryptography and Go uses github.com/decred/dcrd/dcrec/secp256k1/v4. Blockstream does not maintain these libraries. The install lines pin the versions we tested (50.0.2 and v4.4.1).
import { createECDH, createHash, createPrivateKey, sign } from "node:crypto";
// --- signing helpers (see Signing requests) ---
// Canonical JSON: keys sorted recursively, no whitespace. Use integers or strings for numbers
// (non-integer numbers are formatted differently by JavaScript and the registry).
function canonicalJson(v) {
if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
if (v && typeof v === "object") return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(",")}}`;
return JSON.stringify(v);
}
// Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message.
function signMessage(message, privateKeyHex) {
const varint = (n) => (n < 0xfd ? Buffer.from([n]) : Buffer.from([0xfd, n & 0xff, n >> 8]));
const msg = Buffer.from(message, "utf8");
const magic = Buffer.from("Bitcoin Signed Message:\n");
const firstHash = createHash("sha256").update(Buffer.concat([varint(magic.length), magic, varint(msg.length), msg])).digest();
const d = Buffer.from(privateKeyHex, "hex");
const ecdh = createECDH("secp256k1");
ecdh.setPrivateKey(d);
const pub = ecdh.getPublicKey();
const key = createPrivateKey({ format: "jwk", key: { kty: "EC", crv: "secp256k1", d: d.toString("base64url"),
x: pub.subarray(1, 33).toString("base64url"), y: pub.subarray(33).toString("base64url") } });
const sig = sign("sha256", firstHash, { key, dsaEncoding: "ieee-p1363" }); // ECDSA-SHA256 adds the second hash
const n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
const s = BigInt("0x" + sig.subarray(32).toString("hex"));
if (s > n / 2n) sig.write((n - s).toString(16).padStart(64, "0"), 32, "hex"); // low-S form
return sig.toString("base64");
}
// Compressed public key (66 hex) for a private key.
function publicKeyOf(privateKeyHex) {
const ecdh = createECDH("secp256k1");
ecdh.setPrivateKey(Buffer.from(privateKeyHex, "hex"));
return ecdh.getPublicKey("hex", "compressed");
}# pip install cryptography==50.0.2
import base64
import hashlib
import json
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
def canonical_json(value):
"""Canonical JSON: keys sorted recursively, no whitespace. Prefer integers or strings for numbers."""
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def sign_message(message, private_key_hex):
"""Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message."""
msg = message.encode("utf-8")
varint = lambda n: bytes([n]) if n < 0xFD else b"\xfd" + n.to_bytes(2, "little")
payload = varint(24) + b"Bitcoin Signed Message:\n" + varint(len(msg)) + msg
key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
first_hash = hashlib.sha256(payload).digest() # ECDSA-SHA256 adds the second hash
r, s = decode_dss_signature(key.sign(first_hash, ec.ECDSA(hashes.SHA256())))
n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
s = min(s, n - s) # low-S form
return base64.b64encode(r.to_bytes(32, "big") + s.to_bytes(32, "big")).decode()
def public_key_of(private_key_hex):
"""Compressed public key (66 hex) for a private key."""
key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
return key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.CompressedPoint).hex()// go get github.com/decred/dcrd/dcrec/secp256k1/[email protected]
// Helper file: add it to your main package (rename the package) or import it.
package registrysign
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
)
// canonicalJSON: keys sorted (Go sorts map keys), no whitespace, no HTML escaping.
func canonicalJSON(v any) []byte {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
panic(err)
}
return bytes.TrimRight(buf.Bytes(), "\n")
}
// signMessage returns a Bitcoin Signed Message signature (Base64, 64-byte R||S).
func signMessage(message []byte, privateKeyHex string) string {
key, err := hex.DecodeString(privateKeyHex)
if err != nil {
panic(err)
}
var payload bytes.Buffer
writeVarint(&payload, 24)
payload.WriteString("Bitcoin Signed Message:\n")
writeVarint(&payload, len(message))
payload.Write(message)
first := sha256.Sum256(payload.Bytes())
digest := sha256.Sum256(first[:])
sig := ecdsa.SignCompact(secp256k1.PrivKeyFromBytes(key), digest[:], true) // recovery byte + R||S, low-S
return base64.StdEncoding.EncodeToString(sig[1:])
}
func writeVarint(b *bytes.Buffer, n int) {
if n < 0xfd {
b.WriteByte(byte(n))
return
}
b.WriteByte(0xfd)
b.WriteByte(byte(n))
b.WriteByte(byte(n >> 8))
}
// publicKeyOf returns the compressed public key (66 hex) for a private key.
func publicKeyOf(privateKeyHex string) string {
key, err := hex.DecodeString(privateKeyHex)
if err != nil {
panic(err)
}
return hex.EncodeToString(secp256k1.PrivKeyFromBytes(key).PubKey().SerializeCompressed())
}Common mistakes
- Sending a re-serialized body. Most HTTP clients re-serialize objects. Send the exact string you signed.
- Go's JSON escaping.
encoding/jsonescapes&,<and>(e.g. in URLs). Use an encoder withSetEscapeHTML(false). - High-S signatures from your own signer. The registry accepts them, but many verifiers don't. Normalize to low-S.
Keys
Generate a key with the TypeScript SDK, or use one you already have: any secp256k1 key works, from a wallet, HSM or KMS. The public key goes in your contract (initial_issuer_pubkey); keep the private key secret.
import { AssetRegistryClient } from "liquid-asset-registry-sdk";
const { privateKey, pubkey } = await AssetRegistryClient.generateKeyPair();
console.log("public key:", pubkey); // compressed, 66 hex chars
// privateKey is 32 bytes: store it in your secrets manager, never log itThe SDK also accepts any external signer, so keys can stay in an HSM or a remote signing service.
Keep private keys server-side. Never put an issuer or admin key in front-end code.
TypeScript SDK
The repository ships a TypeScript SDK for Node.js 18.18+ and modern browsers: typed clients for v2, legacy and admin endpoints, canonical JSON and signing helpers, retries, and pluggable signers.
The SDK isn't on npm yet (
liquid-asset-registry-sdkreturns 404). Build it from the repository.
git clone https://github.com/Blockstream/asset_registry_v2
cd asset_registry_v2/sdk
npm ci && npm run build
npm pack # then: npm install /path/to/liquid-asset-registry-sdk-0.1.0.tgzimport { AssetRegistryClient, AdminClient, Signer } from "liquid-asset-registry-sdk";
const registry = new AssetRegistryClient({
baseUrl: "https://assets.blockstream.info",
issuerPrivateKey: process.env.ISSUER_PRIVATE_KEY, // or signer: any { signData(), getPubkey() }
});
const asset = await registry.v2.get("aa909f1b77451e409fe95fe1d3638ad017ab3325c6d4f00301af6d582d0f2034");
await registry.v2.replaceCategoryTags(assetId, ["stablecoin"]);
await registry.v2.setCustomField(assetId, "isin", "US0000000001");
await registry.v2.rotateIssuerKey(assetId, newPubkey);
// Icons: pass a microsecond timestamp (the default fails; see Known issues)
await registry.v2.proposeIcon(assetId, pngBytes, { timestamp: new Date().toISOString().replace("Z", "000Z") });
const audit = await registry.v2.getAssetAudit(assetId, { order: "asc" });
const { privateKey, pubkey } = await AssetRegistryClient.generateKeyPair();- Issuer helpers fetch
prev_action_hash, fill nonce and timestamp, sign once and reuse the same body on retries. - External signers: implement
signData(canonicalJson): Promise<base64>; the repo has a remote-signer example (sdk/examples/remote-signer). registry.v2.register()isn't retried (registration isn't idempotent).
Updated about 4 hours ago