The signing model
Varnir has two distinct signing paths, and it is worth being clear which is which, because they authenticate different things.
| Ledger transactions | API requests | |
|---|---|---|
| Key | Your identity key | A separate API key pair |
| 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 reason is concrete: the SDK's
own path goes through Node's native crypto.createSign with a PEM-parsed key,
which throws Cannot sign with secp256k1 supplied PEM under Cloudflare
Workers' nodejs_compat crypto shim — the exact same flow that works under
plain Node fails once deployed to a Worker. 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): lowercase-hex HMAC-SHA256 of the raw JSON body, verified against your webhook secret.
Different algorithm, different encoding, different key. The webhook page has the verification code for the inbound one.
Key material
@varnir/signing produces compressed (33-byte) secp256k1 public keys, and
that is what everything here expects. derivePrivateKeyFromPhrase does real
BIP32/BIP44 derivation from a 12-word BIP39 phrase at m/44'/1'/0'/0/0.
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.