astrid-runtime/astrid · critical

signed channel generation rollback rejected

Error message

signed channel generation rollback rejected

What it means

This guard enforces monotonic generation numbers on the signed update channel: a newly proposed channel pointer whose generation is lower than the currently persisted previous generation is rejected. This prevents rollback attacks where an attacker republishes an older signed channel file to downgrade clients.

Source

Thrown at crates/astrid-cli/src/commands/update_channel.rs:755

        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
        Err(error) => return Err(error).context("could not read accepted channel state"),
    };
    let text =
        std::str::from_utf8(&previous_bytes).context("accepted channel state is not UTF-8")?;
    let previous: ChannelPointer =
        toml::from_str(text).context("accepted channel state is invalid TOML")?;
    validate_pointer(&previous, channel, None)?;
    enforce_continuity_values(candidate, candidate_bytes, &previous, &previous_bytes)
}

fn enforce_continuity_values(
    candidate: &ChannelPointer,
    candidate_bytes: &[u8],
    previous: &ChannelPointer,
    previous_bytes: &[u8],
) -> anyhow::Result<()> {
    if candidate.generation < previous.generation {
        bail!("signed channel generation rollback rejected");
    }
    if candidate.generation == previous.generation && candidate_bytes != previous_bytes {
        bail!("signed channel same-generation equivocation rejected");
    }
    Ok(())
}

pub(super) fn persist_accepted(
    channel: UpdateChannel,
    pointer: &[u8],
    bundle: &[u8],
) -> anyhow::Result<()> {
    let (pointer_path, bundle_path) = state_paths(channel)?;
    let dir = pointer_path
        .parent()
        .ok_or_else(|| anyhow::anyhow!("channel state path has no parent"))?;
    std::fs::create_dir_all(dir).context("could not create channel state directory")?;
    // The pointer is the continuity commit marker and is replaced last. A

View on GitHub (pinned to affd8760f4)

Solutions

  1. Bump the candidate generation number to at least previous.generation + 1 before publishing
  2. Re-publish from the latest channel pointer state, not a stale local copy
  3. Fetch the current channel file and rebuild the candidate on top of its generation
  4. If a genuine downgrade is required, do it through the sanctioned process rather than lowering generation

Example fix

// before
let candidate = ChannelPointer { generation: previous.generation - 1, .. };
// after
let candidate = ChannelPointer { generation: previous.generation + 1, .. };
Defensive patterns

Strategy: validation

Validate before calling

fn generation_ok(candidate: &ChannelPointer, previous: &ChannelPointer) -> bool { candidate.generation >= previous.generation }

Try / catch

if candidate.generation < previous.generation {
    eprintln!("candidate channel is older than the installed one; refusing downgrade");
    return Ok(ExitCode::FAILURE);
}

Prevention

When it happens

Trigger: Attempting to accept/persist a candidate ChannelPointer whose generation field is numerically less than previous.generation — checked in the validation helper at update_channel.rs:755 called from the signed-channel accept flow.

Common situations: Publishing from a stale checkout or old release metadata file; a compromised/misconfigured publisher pushes an archived channel JSON; clock/generation bookkeeping mistakes in the release pipeline; manually reverting the channel file on the distribution host.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of astrid-runtime/astrid@affd8760f4 (2026-09-09). Data as JSON: /api/errors/dfab87d59abf19ef. Report an issue: GitHub.