JuliusBrussee/caveman · error
must be a canonical base64 Ed25519 public key
Error message
${label} must be a canonical base64 Ed25519 public key What it means
Thrown by decodeReceiptKey when the key field is base64-decodable but is not a canonical base64 encoding of exactly 32 bytes — i.e. either the decoded length differs from 32, or re-encoding the decoded bytes does not reproduce the original string (non-canonical padding, extra whitespace, or trailing bits set). This canonicality check prevents ambiguous key encodings.
Solutions
- Re-encode the 32-byte raw key with standard Buffer.from(raw).toString('base64') so padding and alphabet are canonical.
- Strip all whitespace/newlines from the key string before embedding it.
- Check decoded length is exactly 32 bytes: Buffer.from(key, 'base64').length === 32.
- Convert base64url to standard base64 if the source used -/_ characters.
Example fix
// before
key: "MCowBQYDK2VwAyEAmV-4_zQ" // base64url, non-canonical
// after
key: Buffer.from(rawKeyBytes).toString("base64") // canonical std base64, 44 chars Defensive patterns
Strategy: validation
Validate before calling
const raw = Buffer.from(key, 'base64');
if (raw.length !== 32 || raw.toString('base64') !== key) throw new Error('key must be canonical base64 of 32 bytes'); Type guard
function isCanonicalB64Key(key: unknown): key is string {
if (typeof key !== 'string') return false;
const raw = Buffer.from(key, 'base64');
return raw.length === 32 && raw.toString('base64') === key;
} Try / catch
try {
verifyBundle(bundle);
} catch (e) {
if ((e as Error).message.includes('canonical base64')) console.error('re-encode key with Buffer.from(raw).toString("base64")');
throw e;
} Prevention
- Always produce key strings via Buffer.from(raw).toString('base64').
- Strip whitespace/newlines before embedding keys in JSON.
- Convert base64url to standard base64 at export time.
When it happens
Trigger: Verifying a bundle with a key string containing line breaks or whitespace, base64 with missing/incorrect padding, base64url encoding (- and _ instead of + and /), or key material longer/shorter than 32 bytes when decoded.
Common situations: Copy/paste wrapping a key across lines; a PEM body pasted including headers; base64url from a JWT-style tool; a hex string mistakenly base64-encoded.
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
- bundle completeness cannot be attested by unsigned export…
- ed25519 public key must be 32 bytes, got
- envelope: object kind is required
- envelope: organization scope is required
- is not canonical base64
AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20).
Data as JSON: /api/errors/dd95b544db9ec58d.
Report an issue: GitHub.
Appendix: source
Thrown at packages/cli/src/index.ts:17852
if (!decoded) return `seq ${r.seq}: no trusted public key for key_id ${String(r.signature?.key_id)}`;
const err = verifyReceipt(r, decoded.key, decoded.info.key_id);
if (err) return err;
if (prev) {
if (r.seq !== prev.seq + 1) return `seq ${r.seq}: not strictly after ${prev.seq}`;
if (r.prev_receipt_hash !== prev.receipt_hash) return `seq ${r.seq}: prev_receipt_hash does not link to seq ${prev.seq}`;
if (r.day <= prev.day) return `seq ${r.seq}: day ${r.day} does not follow ${prev.day}`;
}
prev = r;
}
return null;
}
function decodeReceiptKey(info: ReceiptPublicKey, label: string): DecodedReceiptKey {
if (!info || typeof info.key_id !== "string" || !info.key_id.trim()) throw new Error(`${label} key_id is required`);
if (info.alg !== "Ed25519") throw new Error(`${label} has unsupported algorithm ${String(info.alg)}`);
if (typeof info.key !== "string" || !info.key.trim()) throw new Error(`${label} key is required`);
const raw = Buffer.from(info.key, "base64");
if (raw.length !== 32 || raw.toString("base64") !== info.key) throw new Error(`${label} must be a canonical base64 Ed25519 public key`);
return { info, raw, key: ed25519PublicKey(raw) };
}
function decodeUniqueKeyring(infos: ReceiptPublicKey[], label: string): Map<string, DecodedReceiptKey> {
const keys = new Map<string, DecodedReceiptKey>();
for (const [index, info] of infos.entries()) {
const decoded = decodeReceiptKey(info, `${label}[${index}]`);
if (keys.has(decoded.info.key_id)) throw new Error(`${label} contains duplicate key_id ${decoded.info.key_id}`);
keys.set(decoded.info.key_id, decoded);
}
return keys;
}
function embeddedReceiptKeys(bundle: ReceiptBundle): { current: DecodedReceiptKey; keys: Map<string, DecodedReceiptKey> } {
if (bundle.schema !== RECEIPT_BUNDLE_V1 && bundle.schema !== RECEIPT_BUNDLE_V2) throw new Error(`unsupported bundle schema ${String(bundle.schema)}`);
if (bundle.verification_coverage !== undefined && bundle.verification_coverage !== INCLUDED_RECEIPTS_ONLY) throw new Error(`unsupported unsigned verification coverage ${String(bundle.verification_coverage)}`);
if (bundle.completeness_attested === true) throw new Error("bundle completeness cannot be attested by unsigned export metadata");
const current = decodeReceiptKey(bundle.public_key, "public_key");View on GitHub (pinned to 3ee70a1026)