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
- Re-download the asset from scratch (no resume) and re-verify — most mismatches are incomplete downloads.
- 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).
- 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.
- 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
- Read asset bytes with fs.readFileSync(path) (Buffer) — never pass an encoding that rewrites bytes.
- Download assets atomically (temp file + rename) so partial files aren't verified.
- Cross-check expected hash with `sha256sum` in CI before invoking verifyRelease.
- Treat a persistent mismatch as a security incident, not a transient failure.
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
- SHA256SUMS has no entry for ${input.assetFilename}
- frozen human eval hash mismatch — set has drifted (got ${cor
- SSRF guard: invalid URL — ${rawUrl}
- SSRF guard: only HTTPS URLs are permitted, got ${parsed.prot
- SSRF guard: private/loopback host rejected — ${host}
AI-assisted analysis of ruvnet/ruflo@6b01dc5a68 (2026-08-12).
Data as JSON: /api/errors/159535c0b25eebc4.
Report an issue: GitHub.