Skip to main content

The signing model

Varnir has two distinct signing paths, and it is worth being clear which is which, because they authenticate different things.

Ledger transactionsAPI requests
KeyYour identity keyA separate API key pair
SignsThe $tx objectrouteTag \n timestamp \n body
Sent toA chain node, directlyVarnir's HTTP API
HelpersignPayloadBase64buildApiKeyAuthHeaders
ProvesThis identity authorised this state changeThis 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:

  • routeTag is 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.
  • bodyText is the raw string. Serialise your body once, sign that string, and send that same string. Do not JSON.stringify twice.

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.