JuliusBrussee/caveman · error

v2 public_keys must include public_key

Error message

v2 public_keys must include public_key

What it means

In a v2 bundle the public_keys keyring is authoritative, so the top-level public_key (the current signing key) must appear inside it. If no public_keys entry has the current key's key_id, verification throws "v2 public_keys must include public_key".

Source

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

  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");
  if (bundle.public_keys !== undefined && !Array.isArray(bundle.public_keys)) throw new Error("public_keys must be an array");
  if (bundle.schema === RECEIPT_BUNDLE_V2 && (!Array.isArray(bundle.public_keys) || bundle.public_keys.length === 0)) throw new Error("v2 bundle requires public_keys");
  const keys = decodeUniqueKeyring(bundle.public_keys ?? [], "public_keys");
  const currentInRing = keys.get(current.info.key_id);
  if (currentInRing && !currentInRing.raw.equals(current.raw)) throw new Error(`public_key conflicts with public_keys entry ${current.info.key_id}`);
  if (bundle.schema === RECEIPT_BUNDLE_V2 && !currentInRing) throw new Error("v2 public_keys must include public_key");
  if (!currentInRing) keys.set(current.info.key_id, current);
  return { current, keys };
}

async function pinnedReceiptKeys(file: string, current: DecodedReceiptKey): Promise<{ keys: Map<string, DecodedReceiptKey>; trust: string }> {
  const source = (await readFile(file, "utf8")).trim();
  if (!source.startsWith("{")) {
    const pinned = decodeReceiptKey({ ...current.info, key: source }, "--pubkey");
    if (!pinned.raw.equals(current.raw)) throw new Error("bundle public key does not match the published --pubkey");
    return { keys: new Map([[current.info.key_id, pinned]]), trust: "pinned_public_key" };
  }
  let parsed: { public_key?: ReceiptPublicKey; public_keys?: ReceiptPublicKey[] };
  try { parsed = JSON.parse(source); } catch { throw new Error("--pubkey JSON is malformed"); }
  const infos = Array.isArray(parsed.public_keys) ? parsed.public_keys : parsed.public_key ? [parsed.public_key] : [];
  if (infos.length === 0) throw new Error("--pubkey JSON must contain public_key or public_keys");
  const keys = decodeUniqueKeyring(infos, "--pubkey public_keys");
  const pinnedCurrent = keys.get(current.info.key_id);
  if (!pinnedCurrent || !pinnedCurrent.raw.equals(current.raw)) throw new Error("trusted --pubkey keyring does not contain the bundle public key");

View on GitHub (pinned to 5184b3d11a)

Solutions

  1. Add the current signing key (same key_id and bytes as public_key) to public_keys and re-export
  2. Regenerate the bundle with current producer code, which always includes the active key
  3. Treat a v2 export missing its active key as producer output you should not trust

Example fix

// before
"public_keys": [ { "key_id": "k1", ... } ]  // current key is k2

// after
"public_keys": [ { "key_id": "k1", ... }, { "key_id": "k2", ... } ]  // includes current key
Defensive patterns

Strategy: validation

Validate before calling

if (bundle.schema === "caveman.receipt-bundle.v2") {
  const included = (bundle.public_keys ?? []).some((k) => k.key_id === bundle.public_key.key_id);
  if (!included) throw new Error("v2 keyring must contain the current signing key");
}

Type guard

function v2KeyringCoversCurrentKey(b: { schema?: unknown; public_key?: { key_id?: unknown }; public_keys?: { key_id?: unknown }[] }): boolean {
  if (b.schema !== "caveman.receipt-bundle.v2") return true;
  return (b.public_keys ?? []).some((k) => k?.key_id === b.public_key?.key_id);
}

Try / catch

try { execSync(`caveman receipts verify ${bundle}`); }
catch (e) {
  if (/v2 public_keys must include public_key/.test(String((e as Error).message))) fail("producer bug: active key missing from keyring");
  throw e;
}

Prevention

When it happens

Trigger: A v2 bundle where public_keys exists but was exported without the current signing key (e.g. only historical rotation keys), or the keyring was hand-pruned.

Common situations: Export filters the keyring to rotated-out keys; a producer bug lists only past keys; manual removal of the current key while trimming.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@5184b3d11a (2026-08-18). Data as JSON: /api/errors/80448eed77627972. Report an issue: GitHub.