Skip to main content

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 transactionsAPI requests
KeyYour identity keyYour identity key, or any other authority on it
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 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:

  • 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): v1= followed by the lowercase-hex HMAC-SHA256 of timestamp.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.