The signing model
Varnir has two distinct signing paths, and they authenticate different things.
VarnirClient does both for you — this page is what it does underneath, and
what to use directly if you are building a transaction it does not.
| Ledger transactions | API requests | |
|---|---|---|
| Key | Your identity key | Your identity key, or any other authority on it |
| Signs | The $tx object | routeTag \n timestamp \n body |
| Sent to | A chain node, directly | Varnir's HTTP API |
| Helper | signPayloadBase64 | buildApiKeyAuthHeaders |
| Proves | This identity authorised this state change | This request came from a holder of this key |
Both use the same primitive: SHA-256 over the message, ECDSA over secp256k1, DER-encoded, base64-encoded.
signPayloadBase64
import {signPayloadBase64} from '@varnir/chain-client';
const signature = signPayloadBase64(tx.$tx, privateKeyHex);
The payload is JSON.stringify'd (if it is not already a string), SHA-256
hashed, signed with secp256k1, DER-encoded and base64'd. privateKeyHex may be
0x-prefixed or not.
This is a pure-JS reimplementation of what the Activeledger SDK does
internally, rather than a wrapper around it: the SDK's own path goes through
Node's native crypto.createSign with a PEM-parsed key, which throws under
Cloudflare Workers' nodejs_compat crypto shim. Only this one primitive
needed replacing; transaction shape and submission are unaffected.
It is byte-for-byte interchangeable with the SDK's own signing — a transaction signed this way gets a real vote and commit from a live node.
verifyPayloadBase64
The mirror image, used server-side:
import {verifyPayloadBase64} from '@varnir/chain-client';
const ok = verifyPayloadBase64(payload, signatureBase64, publicKeyHex);
Returns false rather than throwing on malformed input.
buildApiKeyAuthHeaders
import {buildApiKeyAuthHeaders} from '@varnir/chain-client';
const headers = buildApiKeyAuthHeaders(
'POST /api/invoices', // routeTag - a fixed literal, see below
bodyText, // the EXACT string you will send as the body
apiKeyPair.publicKeyHex,
apiKeyPair.privateKeyHex,
);
// { 'X-Varnir-Api-Key', 'X-Varnir-Timestamp', 'X-Varnir-Signature' }
The signed message is:
${routeTag}\n${timestamp}\n${bodyText}
Two details matter:
routeTagis a fixed literal, matching the server's own constant — not derived from the request URL. That is what stops a signature being replayed against a different route just because the two bodies happen to have compatible shapes.bodyTextis the raw string. Serialise your body once, sign that string, and send that same string. Do notJSON.stringifytwice.
X-Varnir-Api-Key is the public key itself — there is no opaque key id, and no
shared secret in the request.
Two different X-Varnir-Signature headers
The same header name is used in both directions, for different things. Do not confuse them:
- Outbound, you → Varnir (this page): base64 DER ECDSA/secp256k1
signature over
routeTag\ntimestamp\nbody, verified against your registered API public key. - Inbound, Varnir → you (webhooks):
v1=followed by the lowercase-hex HMAC-SHA256 oftimestamp.body, verified against your webhook secret.
X-Varnir-Timestamp differs too: unix milliseconds on a request you send,
unix seconds on a webhook Varnir sends you.
Different algorithm, different encoding, different key. The webhook page has the verification code for the inbound one.
Key material
@varnir/chain-client/keys produces compressed (33-byte) secp256k1 public
keys, and that is what everything here expects. derivePrivateKeyFromPhrase
derives from a 12-word BIP-39 phrase through upstream's construction —
HMAC-SHA512("Bitcoin seed", seed)[0..32], the BIP-32 master scalar —
matching every other Activeledger SDK. See
the core contract for details.
The compressed, 0x-prefixed hex rendering is not cosmetic: Varnir.Onboard
hashes that literal string to name your identity stream, so the same key written
a different way onboards to a different identity. VarnirClient derives it for
you from the private key (publicKeyForPrivateKey), which is why you only ever
hand it the private half.
If you already have a key pair derived elsewhere, importEllipticCurveKey
(exported from @varnir/chain-client/node and /web) wraps a compressed
secp256k1 pair into the shape the transports accept. The uncompressed form is
not supported — pass the compressed public key.