ruvnet/ruflo · critical · ReleaseVerificationError

sha256 mismatch for ${input.assetFilename}: expected ${expec

Error message

sha256 mismatch for ${input.assetFilename}: expected ${expected.slice(0, 12)}…, got ${actual.slice(0, 12)}…

What it means

Thrown by verifyRelease() when the Ed25519 signature on SHA256SUMS passed AND the asset filename is present in the manifest, but sha256Hex(assetBytes) does not equal the expected hex from the signed manifest. Of the three verification gates this is the strongest tamper signal: a trusted manifest says what the hash should be, and the bytes you have don't match. ADR-307 mandates refuse-on-any-mismatch with no partial-trust outcome, so this must abort the install.

Source

Thrown at v3/@claude-flow/cli/src/proxy/verify.ts:90

 * Full verification: signature over SHA256SUMS, then the asset's own hash
 * against the matching line. Throws `ReleaseVerificationError` on ANY
 * failure — there is no partial-trust outcome, matching ADR-307's "refuses
 * on any mismatch" requirement.
 */
export function verifyRelease(input: VerifyReleaseInput): VerifyReleaseResult {
  if (!verifySha256SumsSignature(input.sumsBytes, input.sigBase64, input.pubkeyPem)) {
    throw new ReleaseVerificationError('SHA256SUMS.sig failed Ed25519 verification — refusing to install');
  }

  const sums = parseSha256Sums(input.sumsBytes.toString('utf-8'));
  const expected = sums[input.assetFilename];
  if (!expected) {
    throw new ReleaseVerificationError(`SHA256SUMS has no entry for ${input.assetFilename}`);
  }

  const actual = sha256Hex(input.assetBytes);
  if (actual !== expected) {
    throw new ReleaseVerificationError(
      `sha256 mismatch for ${input.assetFilename}: expected ${expected.slice(0, 12)}…, got ${actual.slice(0, 12)}…`,
    );
  }

  return { sha256: actual };
}

View on GitHub (pinned to 6b01dc5a68)

Solutions

  1. Re-download the asset from scratch (no resume) and re-verify — most mismatches are incomplete downloads.
  2. Confirm you are reading the asset with the same binary fidelity it was signed with: fs.readFileSync(path) with no encoding argument (Buffer, not utf-8 string).
  3. Cross-check the expected hash from SHA256SUMS against a fresh `sha256sum <file>` in your shell — if those agree but verifyRelease still fails, assetBytes is being transformed before the call.
  4. If the mismatch persists on a clean re-download, escalate to the release signer/maintainer: either the manifest or the published binary is wrong, and installing is unsafe.

Example fix

// before — assetBytes possibly transformed (text read, re-encoded)
const assetBytes = Buffer.from(fs.readFileSync(path, 'utf-8'), 'utf-8');
verifyRelease({ assetFilename, sumsBytes, sigBase64, assetBytes });

// after — read raw bytes, no encoding conversion
const assetBytes = fs.readFileSync(path); // Buffer, binary-safe
verifyRelease({ assetFilename, sumsBytes, sigBase64, assetBytes });
Defensive patterns

Strategy: validation

Validate before calling

import { createHash } from 'node:crypto';
import { parseSha256Sums, verifySha256SumsSignature } from './verify';

function preflightAsset(input: {
  sumsBytes: Buffer; sigBase64: string; assetBytes: Buffer; assetFilename: string;
}): { ok: true; sha256: string } | { ok: false; reason: string } {
  if (!verifySha256SumsSignature(input.sumsBytes, input.sigBase64)) {
    return { ok: false, reason: 'signature failed — do not install' };
  }
  const sums = parseSha256Sums(input.sumsBytes.toString('utf-8'));
  const expected = sums[input.assetFilename];
  if (!expected) return { ok: false, reason: 'no manifest entry' };
  const actual = createHash('sha256').update(input.assetBytes).digest('hex');
  return actual === expected
    ? { ok: true, sha256: actual }
    : { ok: false, reason: `expected ${expected}, got ${actual}` };
}

// re-download if preflight fails before calling verifyRelease
const check = preflightAsset({ sumsBytes, sigBase64, assetBytes, assetFilename });
if (!check.ok && /expected .* got/.test(check.reason)) {
  assetBytes = fs.readFileSync(reDownloadAsset(assetFilename)); // fresh download
}

Try / catch

try {
  verifyRelease(input);
} catch (e) {
  if (e instanceof ReleaseVerificationError && e.message.startsWith('sha256 mismatch')) {
    // tamper or corruption. Re-download ONCE from a trusted source; if it still
    // fails, halt and escalate — do not loop.
    await reDownloadFromTrustedSource(input.assetFilename);
    verifyRelease({ ...input, assetBytes: fs.readFileSync(localPath) });
  } else {
    throw e;
  }
}

Prevention

When it happens

Trigger: Truncated or partially downloaded asset (network drop, proxy timeout mid-stream); corrupted cache (disk error, interrupted previous write); a genuinely tampered binary (MITM, compromised mirror); verifying a re-compressed/repackaged artifact whose bytes differ from what was signed; reading the file in text mode on Windows so line endings are rewritten.

Common situations: CDN served a stale cached copy from a previous release under the new URL; a corporate proxy re-compressed the tarball; the download was resumed with `curl -C -` against a different version; the asset was piped through a tool that strips/converts bytes (git's autocrlf, a Docker layer re-tar); the file was written with a different encoding flag in readFileSync.

Related errors


AI-assisted analysis of ruvnet/ruflo@6b01dc5a68 (2026-08-12). Data as JSON: /api/errors/159535c0b25eebc4. Report an issue: GitHub.