@kerits/core
Guides

CESR Encoding and Decoding

Encode and decode keys, signatures, and digests using the Composable Event Streaming Representation.

CESR (Composable Event Streaming Representation) is the binary/text encoding format used throughout KERI to represent cryptographic primitives — public keys, signatures, digests, and identifiers. Every value in a KEL event that carries cryptographic material is a CESR qb64 string: a self-framing, base-64-URL-safe encoding that embeds its own algorithm code in the first one to four characters.

The code prefix tells any reader exactly what the value is and how long it is, without needing a schema or length field. For example, Ed25519 public keys start with D (transferable) or B (non-transferable), BLAKE3-256 digests start with E, and Ed25519 signatures start with 0B.

Encode and decode public keys

encodeKey wraps raw Ed25519 key bytes in the correct CESR code. Pass transferable: false for non-transferable (witness) keys.

keys.ts
import { generateKeyPair, encodeKey, decodeKey } from '@kerits/core';

const { publicKey } = generateKeyPair();

// Encode: raw bytes → qb64
const encoded = encodeKey(publicKey);
// encoded.qb64: 'D...' (44 chars, transferable Ed25519)
// encoded.algo: 'ed25519'
// encoded.raw:  Uint8Array (32 bytes)

// Non-transferable (witness) key uses 'B' prefix
const nonTransferable = encodeKey(publicKey, false);
// nonTransferable.qb64: 'B...'

// Decode: qb64 → raw bytes + algo
const decoded = decodeKey(encoded.qb64);
// decoded.raw:  Uint8Array (32 bytes)
// decoded.algo: 'ed25519'
// decoded.qb64: 'D...'

decodeKey throws if the code is not a recognized key type.

Encode and decode signatures

encodeSignature (exported as encodeSig internally) wraps a 64-byte Ed25519 signature in CESR qb64 form.

sigs.ts
import { sign, encodeSignature, decodeSignature, generateKeyPair, canonicalizeToBytes } from '@kerits/core';

const { publicKey, privateKey } = generateKeyPair();
const bytes = canonicalizeToBytes({ hello: 'keri' });
const rawSig = sign(bytes, privateKey);

// Encode
const encoded = encodeSignature(rawSig);
// encoded.qb64: '0B...' (88 chars, transferable Ed25519 sig)
// encoded.algo: 'ed25519'
// encoded.raw:  Uint8Array (64 bytes)

// Decode
const decoded = decodeSignature(encoded.qb64);
// decoded.raw:  Uint8Array (64 bytes)

Encode and decode digests

encodeDigest and decodeDigest operate on raw digest bytes. They are used, for example, when you compute a hash outside of KERI and need to represent it as a CESR value.

digest.ts
import { encodeDigest, decodeDigest, encode, decode } from '@kerits/core';

// encodeDigest uses Blake3-256 ('E' prefix) by default
const digestBytes = new Uint8Array(32).fill(0xab); // placeholder
const qb64 = encodeDigest(digestBytes);
// qb64: 'E...' (44 chars)

// decodeDigest validates the code is a known digest type
const decoded = decodeDigest(qb64);
// decoded.code: 'E'
// decoded.raw:  Uint8Array (32 bytes)
// decoded.meta: CESRCodeMeta

// Generic encode/decode for any CESR Matter primitive
import { MtrDex } from 'cesr-ts/src/matter'; // if needed for other codes
const generic = encode(digestBytes, 'E');   // BLAKE3-256 code
const back = decode(generic);

Inspect a CESR value

inspect classifies any qb64 string without fully decoding it. Use this when you receive an unknown CESR value and need to determine what kind of primitive it contains before routing it to the correct handler.

inspect.ts
import { inspect } from '@kerits/core';
import type { PrefixInfo } from '@kerits/core';

const keyInfo: PrefixInfo = inspect('Dabc...');
// { code: 'D', length: 44, kind: 'key' }

const sigInfo: PrefixInfo = inspect('0Bsig...');
// { code: '0B', length: 88, kind: 'sig' }

const digestInfo: PrefixInfo = inspect('Edigest...');
// { code: 'E', length: 44, kind: 'digest' }

kind is one of 'key' | 'sig' | 'digest' | 'other'. If the input is not a valid CESR primitive, inspect returns kind: 'other' rather than throwing.

Using the Cesr namespace

All CESR functions are also available as a single Cesr namespace object:

cesr-namespace.ts
import { Cesr } from '@kerits/core';

const encoded = Cesr.encodeKey(publicKeyBytes);
const sig = Cesr.encodeSig(sigBytes);
const info = Cesr.inspect(encoded.qb64);
const meta = Cesr.getCodeMeta('E');
// meta: { code: 'E', family: 'matter', algorithm: 'Blake3_256', rawSize: 32, codeLen: 1 }

CESR code reference

CodeKindAlgorithmRaw size
DkeyEd25519 (transferable)32 bytes
BkeyEd25519 (non-transferable)32 bytes
EdigestBLAKE3-25632 bytes
HdigestSHA3-25632 bytes
IdigestSHA2-25632 bytes
0BsigEd2551964 bytes

CESR Attachment Groups

KERI events are transmitted with cryptographic attachments — signatures, receipts, and seals — encoded as CESR attachment groups. Each group starts with a counter code that declares the group type and item count, followed by the encoded primitives.

Encode attachments

encodeAttachmentGroups takes an array of typed CesrAttachment objects and produces the CESR wire bytes:

encode-attachments.ts
import { encodeAttachmentGroups } from '@kerits/core';
import type { CesrAttachment } from '@kerits/core';

const attachments: CesrAttachment[] = [
  { kind: 'sig', form: 'indexed', keyIndex: 0, sig: '0Bsig...' },
  { kind: 'sig', form: 'indexed', keyIndex: 1, sig: '0Bsig...' },
];

const wireBytes: string = encodeAttachmentGroups(attachments);
// wireBytes: counter prefix + concatenated Siger qb64 values

Supported attachment kinds:

KindDescriptionCounter code
sig (indexed)Controller indexed signatures-A
sig (witness)Witness indexed signatures-B
rctNon-transferable receipt couples-C
vrcTransferable receipt quadruples-D

Decode attachments

decodeAttachmentGroupsFromStream parses CESR wire bytes back into typed attachment objects:

decode-attachments.ts
import { decodeAttachmentGroupsFromStream } from '@kerits/core';

const { attachments, bytesConsumed } = decodeAttachmentGroupsFromStream(wireBytes);

for (const att of attachments) {
  if (att.kind === 'sig' && att.form === 'indexed') {
    console.log(`Signature at key index ${att.keyIndex}: ${att.sig}`);
  } else if (att.kind === 'rct') {
    console.log(`Receipt from ${att.by}: ${att.sig}`);
  } else if (att.kind === 'vrc') {
    console.log(`Transferable receipt: seal=${att.seal.i}, sig=${att.sig}`);
  }
}

The decoder is streaming-capable: bytesConsumed tells you how many bytes were processed, so you can parse multiple concatenated groups or mixed event+attachment streams.

KERIpy Compliance Testing

Kerits validates its CESR encoding against KERIpy using the same fixture-based cross-validation strategy used for SAID compliance.

How it works

┌─────────────────────┐     ┌──────────────────────┐     ┌─────────────────────────┐
│ 1. Neutral fixtures │ ──▶ │ 2. KERIpy generates  │ ──▶ │ 3. TypeScript tests     │
│    (shared JSON)    │     │    expected values    │     │    cross-validate       │
└─────────────────────┘     └──────────────────────┘     └─────────────────────────┘

Fixtures live in packages/core/src/cesr/fixtures/cesr-fixtures.json. Each fixture declares a counter family, a list of items with neutral field names (hex-encoded raw bytes, key indices, prefix qb64 values), and the expected counter code:

cesr-fixtures.json (excerpt)
{
  "id": "A-single-indexed-sig",
  "counterCode": "-A",
  "family": "controllerIndexedSigs",
  "items": [
    {
      "sigAlg": "Ed25519",
      "keyIndex": 0,
      "sigRaw": "0000...0000"
    }
  ]
}

Expected values are generated by scripts/generate-keripy-cesr.py, which encodes each fixture using KERIpy's CESR primitives (Siger, Matter, Verfer, Cigar, Counter) and writes the results to cesr-expected.json:

Generate expected values
pip install keri
python3 scripts/generate-keripy-cesr.py

TypeScript tests in cesr-cross-validation.test.ts run two layers:

  • Per-item encoding (high-value comparison): verifies that kerits produces identical Siger qb64 and Matter qb64 values as keripy for each primitive
  • Round-trip: encodes via kerits, decodes back, and verifies semantic fields match the fixture

Counter code table versioning

KERIpy 1.3.4 uses the KERI v2 counter code table (CtrDex_2_0), while cesr-ts uses the KERI v1 table. Counter codes differ between versions (e.g. -J vs -A for controller indexed sigs), but per-item primitive encoding is identical. The cross-validation targets per-item encoding, not the full wire including the counter prefix. This means the tests validate interoperability at the cryptographic primitive level regardless of which counter code table version is in use.

Canonical publish wire today: kerits encodeAttachmentGroups emits v1 (-A controller indexed sigs). keripy reference wire and cesr-expected.json use v2 (-J). Full-byte interoperability with keripy-native parsers requires aligning on v2 or adding a transcoding layer at publish time (tracked decision).

KEL event wire (signed events)

KEL artifacts on the wire are not the same as CESREvent.enc:

ConceptMeaning
CESREvent.encHow the event body is serialized (JSON, CBOR, MGPK)
HTTP application/cesrWire format: canonical JSON body bytes + CESR attachment groups

Fixtures: packages/core/src/kel/fixtures/kel-wire-fixtures.json
Expected (keripy): kel-wire-expected.json from python3 scripts/generate-keripy-kel-wire.py
Tests: kel-wire-cross-validation.test.ts — body bytes vs keripy Serder.raw; v1 vs v2 attachment prefix; publish harness checks bytesB64 on encodeCesrEvent.

Always set bytesB64 on signed events when canonical signing bytes are known (see createSignedInception / assembleSignedEvent). Without it, encodeCesrEvent falls back to JSON.stringify(event) which may diverge from keripy wire bodies.

Covered families

The fixture set covers all four attachment group families: controller indexed signatures (-A), witness indexed signatures (-B), non-transferable receipt couples (-C), and transferable receipt quadruples (-D). Each family has single-item and multi-item fixtures, plus edge cases like wider index encoding (keyIndex >= 64).

Adding new fixtures

  1. Add an entry to cesr-fixtures.json with the family, counter code, and items
  2. Re-run python3 scripts/generate-keripy-cesr.py to update expected values
  3. The data-driven test loop in cesr-cross-validation.test.ts picks up new fixtures automatically

On this page