How To Guides

How to verify hash and signatures from the Ledger


The ledger signs all records it produces with its own key pair. This applies to two categories of communication:

  • HTTP responses: records returned when reading or creating resources (wallets, intents, signers, etc.)
  • HTTP requests made by the ledger: outbound calls such as effect deliveries and two-phase commit (2PC) requests sent to your bridge

Verifying these signatures lets you confirm that the content was genuinely produced by the ledger and has not been tampered with.

Obtaining the ledger's public key

All signing is done by the ledger's built-in System signer. Read it and store the public key, you will need it whether you are using the SDK or verifying proofs manually.

const systemSigner = await sdk.signer.read('system')
const ledgerPublicKey = systemSigner.data.public

Using the SDK client

The recommended approach is to use the ProofVerificationClient exposed by the SDK. Pass the public key obtained above during initialization so the SDK can verify proofs automatically on every response.

import { LedgerSdk } from '@minka/ledger-sdk'

const sdk = new LedgerSdk({
  server: 'https://ledger.minka.io/api/v2',
  signer: { format: 'ed25519-raw', public: ledgerPublicKey },
  verifyResponseProofs: true, // automatically verify all incoming responses
})

You can also verify individual records on demand using the sdk.proofs client:

// Basic verification
await sdk.proofs.verify(record)

// Chained assertions
await sdk.proofs
  .ledger()          // assert the ledger's own key signed it
  .length(1)         // assert at least one proof is present
  .verify(record)

When a proof is invalid the client throws a LedgerSdkError describing the failure.

Manual verification

If you are not using the SDK, you can verify proofs by hand by following the steps below.

Proof ordering is not guaranteed. Because the ledger is a distributed system, the array of proofs on a record may arrive in any order. Always locate the proof you want to verify by its public key rather than by its position in the array.

Step 1: Recalculate the record hash and validate it.

The hash covers the record's data payload, not its metadata (proofs, hash field, etc.). The serialization and hashing algorithm is the same one used when signing requests: RFC 8785 canonicalization followed by a SHA-256 hex digest. See How to hash and sign Ledger requests for the full explanation and the serializeData and createHash helper implementations used in the steps below.

const dataHash = createHash(ledgerResponse.data)
const hashIsValid = dataHash === ledgerResponse.hash

Step 2: Recalculate the signature digest

Each proof may include a custom object with additional data (timestamp, status, etc.). The digest that was actually signed combines the record hash with that custom data using the double-hashing algorithm. See the Signature digests section of the signing guide for the full explanation and the createSignatureDigest helper implementation used below.

For each proof you want to verify, pass the dataHash from the previous step and the proof's custom field:

const digest = createSignatureDigest(dataHash, proof.custom)

You can cross-check digest against the digest field stored on the proof object, they must match before you proceed to signature verification.

Step 3: Verify the signature

With the digest in hand, verify that the proof's result (the actual signature bytes) was produced by the private key that corresponds to ledgerPublicKey.

function verifySignature(
  digest: string,
  proof: LedgerProof,
): boolean {
  const digestBuffer = Buffer.from(digest, 'hex')
  const signatureBuffer = Buffer.from(proof.result, 'base64')

  const key = crypto.createPublicKey({
    format: 'der',
    type: 'spki',
    key: Buffer.from(proof.public, 'base64'),
  })

  // The first argument must be undefined for ed25519, the algorithm
  // uses SHA-512 internally and does not accept an external digest algorithm.
  return crypto.verify(undefined, digestBuffer, key, signatureBuffer)
}

const isValid = verifySignature(digest, proof)

A true return value means the proof was created by the holder of the corresponding private key and that the record data (including any custom proof data) has not been modified.

Putting it all together

function verifyLedgerProof(
  ledgerResponse: any,
  expectedPublicKey: string,
): boolean {
  const { hash, meta, data } = ledgerResponse
  const dataHash = createHash(data)
  const hashIsValid = dataHash === hash

  if ( !hashIsValid ) {
    throw new Error('Invalid ledger response hash')
  }

  // Find the proof signed by the expected public key
  const proof = meta?.proofs?.find(
    (p: LedgerProof) => p.public === expectedPublicKey,
  )

  if (!proof) {
    throw new Error('No proof found for the given public key')
  }

  const digest = createSignatureDigest(dataHash, proof.custom)

  if (digest !== proof.digest) {
    throw new Error('Digest mismatch')
  }

  return verifySignature(digest, proof)
}