JuliusBrussee/caveman · error

ed25519 public key must be 32 bytes, got

Error message

ed25519 public key must be 32 bytes, got ${raw.length}

What it means

Thrown by the internal ed25519PublicKey helper, which wraps a raw 32-byte Ed25519 public key in a DER SPKI structure so node:crypto's createPublicKey can verify receipt signatures. It fires when the decoded key buffer is not exactly 32 bytes, i.e. the supplied key material is truncated, padded, or not a raw Ed25519 public key at all.

Solutions

  1. Re-export the receipt bundle / key from the producing system so the raw 32-byte Ed25519 key is used.
  2. Check that the base64 string decodes to exactly 32 bytes: Buffer.from(key, 'base64').length === 32.
  3. If you have a PEM/SPKI key, extract the raw key: createPublicKey(pem).export({format:'der',type:'spki'}).subarray(12) and base64 that.
  4. Confirm no encoding mangling (whitespace, quotes, URL-encoding) corrupted the key string.

Example fix

// before
const key = Buffer.from(pemString, 'base64'); // length 44, not 32 -> throws
// after
const raw = createPublicKey(pemString).export({ format: 'der', type: 'spki' }).subarray(12);
verifyReceipt(receipt, Buffer.from(raw)); // 32 bytes
Defensive patterns

Strategy: validation

Validate before calling

const raw = Buffer.from(keyB64, 'base64');
if (raw.length !== 32) throw new Error(`expected 32-byte ed25519 key, got ${raw.length}`);

Type guard

function isRawEd25519Key(key: unknown): key is string {
  return typeof key === 'string' && Buffer.from(key, 'base64').length === 32;
}

Try / catch

try {
  verifyReceipt(receipt, bundle);
} catch (e) {
  if ((e as Error).message.includes('must be 32 bytes')) console.error('bad key material:', e.message);
  throw e;
}

Prevention

When it happens

Trigger: Calling receipt verification with a receipt bundle whose public key decodes (from base64) to a buffer whose length !== 32 — e.g. a corrupted, hand-edited, or truncated key field, or a PEM/DER key pasted into a field that expects raw key bytes.

Common situations: Developers pasting a full DER/PEM key where only the raw 32 bytes belong; keys damaged by copy/paste (whitespace stripped or line breaks removed changing base64 decode); exporting from a tool that emits a different key format.

Understand the failure class

Background: "Invalid ... format", "must be in format X", "does not look like a ..." — invalid argument format errors across CLI tools and libraries — this error's family across 17 libraries.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20). Data as JSON: /api/errors/55367c47551a155e. Report an issue: GitHub.

Appendix: source

Thrown at packages/cli/src/index.ts:17720

    previous = method;
  }
  return true;
}

// canonicalize reproduces cloud/metering's canonical form byte-for-byte: compact
// JSON with object keys sorted lexicographically and ES6 (shortest) numbers,
// which JSON.stringify and Go's encoding/json both emit identically.
function canonicalize(value: unknown): string {
  if (value === null || typeof value !== "object") return JSON.stringify(value);
  if (Array.isArray(value)) return "[" + value.map(canonicalize).join(",") + "]";
  const obj = value as Record<string, unknown>;
  return "{" + Object.keys(obj).sort().map((k) => JSON.stringify(k) + ":" + canonicalize(obj[k])).join(",") + "}";
}

// ed25519PublicKey wraps a raw 32-byte key in DER SPKI so node:crypto can verify
// with it (matching Go's raw ed25519 public key).
function ed25519PublicKey(raw: Buffer): KeyObject {
  if (raw.length !== 32) throw new Error(`ed25519 public key must be 32 bytes, got ${raw.length}`);
  const der = Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), raw]);
  return createPublicKey({ key: der, format: "der", type: "spki" });
}

// verifyReceipt recomputes the hash over the canonical core (every field except
// receipt_hash and signature) and verifies the signature over receipt_hash.
function validateReceiptContent(r: Receipt): string | null {
  if (r.schema !== RECEIPT_V1 && r.schema !== RECEIPT_V2 && r.schema !== RECEIPT_V3 && r.schema !== RECEIPT_V4) return `seq ${r.seq}: unsupported receipt schema ${String(r.schema)}`;
  if (!r.scope || !SHA256_VALUE.test(r.scope.org_hash) || !SHA256_VALUE.test(r.scope.project_hash)) return `seq ${r.seq}: invalid receipt scope hash`;
  if (r.schema === RECEIPT_V4) {
    if (r.scope_completeness !== INCLUDED_RECEIPTS_ONLY) return `seq ${r.seq}: scope_completeness must be ${INCLUDED_RECEIPTS_ONLY}`;
    if (!Array.isArray(r.methods)) return `seq ${r.seq}: methods must be an array`;
    if (r.formula_version === RECEIPT_FORMULA && !validBillableReceiptMethods(r.methods)) return `seq ${r.seq}: unsupported billable methods`;
    if (r.formula_version === CAVEBENCH_RECEIPT_FORMULA && r.methods.length !== 0) return `seq ${r.seq}: CaveBench receipts cannot claim verified methods`;
  } else if (r.scope_completeness !== undefined || r.methods !== undefined) {
    return `seq ${r.seq}: signed scope and methods require receipt v4`;
  }
  if (!validReceiptDay(r.day)) return `seq ${r.seq}: invalid receipt day ${String(r.day)}`;

View on GitHub (pinned to 3ee70a1026)