chio-store-sqlite 0.1.2

SQLite-backed persistence, query, and report implementations for Chio
Documentation
use std::collections::BTreeMap;

use chio_core::sha256_hex;
use chio_federation::frost::{
    FrostArtifactTrustStore, FrostEpochCheckpointV1, FrostRosterV1, FrostSessionBurnSummaryV1,
    VerifiedFrostEpochAdvance,
};
use rusqlite::{params, Connection};

use super::rotation::{
    canonical_error, invalid, load_active_record, sqlite_u64, trust_error, StoredRotation,
};
use super::FrostStoreError;

pub(super) fn verify_checkpoint_roster(
    roster: &FrostRosterV1,
    checkpoint: &FrostEpochCheckpointV1,
    artifact_trust: &FrostArtifactTrustStore,
    trusted_now: u64,
) -> Result<(), FrostStoreError> {
    artifact_trust.verify_roster(roster).map_err(trust_error)?;
    roster
        .validate_for_active_resolution()
        .map_err(|error| invalid(error.to_string()))?;
    artifact_trust
        .verify_epoch_checkpoint(checkpoint)
        .map_err(trust_error)?;
    let group_key = hex::decode(&roster.group_public_key)
        .map_err(|_| invalid("active roster group key is not hexadecimal"))?;
    if checkpoint.scope_id != roster.scope_id
        || checkpoint.active_roster_id != roster.roster_id
        || checkpoint.active_roster_digest != roster.roster_digest
        || checkpoint.key_epoch != roster.key_epoch
        || checkpoint.group_public_key_digest != sha256_hex(&group_key)
        || trusted_now < checkpoint.clock_high_water
        || trusted_now < roster.valid_from
        || trusted_now >= roster.valid_until
    {
        return Err(FrostStoreError::Conflict(
            "roster and external epoch checkpoint do not match",
        ));
    }
    Ok(())
}

pub(super) fn verify_advance_checkpoint(
    checkpoint: &FrostEpochCheckpointV1,
    advance: &VerifiedFrostEpochAdvance,
    artifact_trust: &FrostArtifactTrustStore,
) -> Result<(), FrostStoreError> {
    artifact_trust
        .verify_epoch_checkpoint(checkpoint)
        .map_err(trust_error)?;
    let target = advance.target_roster();
    let group_key = hex::decode(&target.group_public_key)
        .map_err(|_| invalid("target roster group key is not hexadecimal"))?;
    if checkpoint.scope_id != target.scope_id
        || checkpoint.checkpoint_sequence != advance.expected_checkpoint_sequence()
        || checkpoint.predecessor_digest.as_deref()
            != Some(advance.predecessor().checkpoint_digest.as_str())
        || checkpoint.active_roster_id != target.roster_id
        || checkpoint.active_roster_digest != target.roster_digest
        || checkpoint.key_epoch != target.key_epoch
        || checkpoint.group_public_key_digest != sha256_hex(&group_key)
        || checkpoint.rotation_authorization_digest.as_deref()
            != Some(advance.rotation_authorization_digest())
        || checkpoint.activation_fence != advance.activation_fence()
        || checkpoint.clock_high_water != advance.clock_high_water()
    {
        return Err(FrostStoreError::Conflict(
            "external epoch checkpoint is not the exact verified successor",
        ));
    }
    Ok(())
}

pub(super) fn verify_stored_successor(
    stored: &StoredRotation,
    checkpoint: &FrostEpochCheckpointV1,
    artifact_trust: &FrostArtifactTrustStore,
) -> Result<(), FrostStoreError> {
    artifact_trust
        .verify_epoch_checkpoint(checkpoint)
        .map_err(trust_error)?;
    let target: FrostRosterV1 =
        serde_json::from_slice(&stored.target_roster_json).map_err(canonical_error)?;
    let group_key = hex::decode(&target.group_public_key)
        .map_err(|_| invalid("target roster group key is not hexadecimal"))?;
    if checkpoint.scope_id != stored.scope_id
        || checkpoint.checkpoint_sequence != stored.expected_checkpoint_sequence
        || checkpoint.predecessor_digest.as_deref()
            != Some(stored.predecessor_checkpoint_digest.as_str())
        || checkpoint.active_roster_id != target.roster_id
        || checkpoint.active_roster_digest != stored.target_roster_digest
        || checkpoint.key_epoch != stored.target_key_epoch
        || checkpoint.group_public_key_digest != sha256_hex(&group_key)
        || checkpoint.rotation_authorization_digest.as_deref()
            != Some(stored.rotation_authorization_digest.as_str())
        || checkpoint.activation_fence != stored.activation_fence
        || checkpoint.clock_high_water != stored.clock_high_water
    {
        return Err(FrostStoreError::Conflict(
            "external epoch checkpoint diverges from the durable stage",
        ));
    }
    Ok(())
}

pub(super) fn verify_active_predecessor(
    connection: &Connection,
    advance: &VerifiedFrostEpochAdvance,
) -> Result<(), FrostStoreError> {
    let active = load_active_record(connection, &advance.target_roster().scope_id)?.ok_or(
        FrostStoreError::Conflict("active predecessor roster is absent"),
    )?;
    if active.key_epoch != advance.predecessor().key_epoch
        || active.roster_digest != advance.predecessor().active_roster_digest
        || active.checkpoint_sequence != advance.predecessor().checkpoint_sequence
        || active.checkpoint_digest != advance.predecessor().checkpoint_digest
        || active.activation_fence != advance.predecessor().activation_fence
        || active.clock_high_water != advance.predecessor().clock_high_water
    {
        return Err(FrostStoreError::Conflict(
            "local active roster is not the verified predecessor",
        ));
    }
    Ok(())
}

pub(super) fn verify_completed_ceremony(
    connection: &Connection,
    target: &FrostRosterV1,
) -> Result<(), FrostStoreError> {
    let expected_shares = target
        .participants
        .iter()
        .map(|participant| {
            (
                participant.participant_id.clone(),
                participant.verification_share.clone(),
            )
        })
        .collect::<BTreeMap<_, _>>();
    let mut statement = connection
        .prepare(
            r#"
            SELECT group_public_key, input_transcript_digest,
                   verification_shares_json
            FROM frost_ceremonies
            WHERE scope_id = ?1 AND key_epoch = ?2 AND state = 'completed'
            "#,
        )
        .map_err(super::sqlite_error)?;
    let rows = statement
        .query_map(
            params![
                &target.scope_id,
                sqlite_u64(target.key_epoch, "target key epoch")?
            ],
            |row| {
                Ok((
                    row.get::<_, String>(0)?,
                    row.get::<_, String>(1)?,
                    row.get::<_, Vec<u8>>(2)?,
                ))
            },
        )
        .map_err(super::sqlite_error)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(super::sqlite_error)?;
    if rows.is_empty() {
        return Err(FrostStoreError::Conflict(
            "target roster has no completed local DKG ceremony",
        ));
    }
    for (group_key, transcript, shares_json) in rows {
        let shares: BTreeMap<String, String> =
            serde_json::from_slice(&shares_json).map_err(canonical_error)?;
        if group_key != target.group_public_key
            || transcript != target.ceremony_transcript_digest
            || shares != expected_shares
        {
            return Err(FrostStoreError::Conflict(
                "target roster does not match the completed DKG ceremony",
            ));
        }
    }
    Ok(())
}

pub(super) fn verify_local_burn_summary(
    connection: &Connection,
    summary: &FrostSessionBurnSummaryV1,
) -> Result<(), FrostStoreError> {
    summary
        .validate()
        .map_err(|error| invalid(error.to_string()))?;
    let table_exists = connection
        .query_row(
            "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'frost_signer_sessions')",
            [],
            |row| row.get::<_, bool>(0),
        )
        .map_err(super::sqlite_error)?;
    if !table_exists {
        if summary.burned_session_ids.is_empty() {
            return Ok(());
        }
        return Err(FrostStoreError::Conflict(
            "burn summary names signer sessions absent from this store",
        ));
    }
    let mut statement = connection
        .prepare(
            r#"
            SELECT session_id, state FROM frost_signer_sessions
            WHERE scope_id = ?1 AND key_epoch = ?2
            ORDER BY session_id
            "#,
        )
        .map_err(super::sqlite_error)?;
    let rows = statement
        .query_map(
            params![
                &summary.scope_id,
                sqlite_u64(summary.key_epoch, "burn key epoch")?
            ],
            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
        )
        .map_err(super::sqlite_error)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(super::sqlite_error)?;
    let burned = signer_burn_tombstones(rows)?;
    if burned != summary.burned_session_ids {
        return Err(FrostStoreError::Conflict(
            "burn summary does not match old-epoch signer tombstones",
        ));
    }
    Ok(())
}

fn signer_burn_tombstones(
    rows: impl IntoIterator<Item = (String, String)>,
) -> Result<Vec<String>, FrostStoreError> {
    let mut sessions = BTreeMap::new();
    for (session_id, state) in rows {
        match state.as_str() {
            "burned" => {
                sessions.insert(session_id, true);
            }
            "completed" => {
                sessions.entry(session_id).or_insert(false);
            }
            _ => {
                return Err(FrostStoreError::Conflict(
                    "live old-epoch signer session remains",
                ))
            }
        }
    }
    let burned = sessions
        .into_iter()
        .filter_map(|(session_id, is_burned)| is_burned.then_some(session_id))
        .collect::<Vec<_>>();
    Ok(burned)
}

pub(super) fn verify_stored_rotation_matches_advance(
    stored: &StoredRotation,
    advance: &VerifiedFrostEpochAdvance,
) -> Result<(), FrostStoreError> {
    if stored.scope_id != advance.target_roster().scope_id
        || stored.predecessor_checkpoint_digest != advance.predecessor().checkpoint_digest
        || stored.target_roster_digest != advance.target_roster().roster_digest
        || stored.target_key_epoch != advance.target_roster().key_epoch
        || stored.rotation_authorization_digest != advance.rotation_authorization_digest()
        || stored.old_session_burn_root != advance.burn_summary().burn_root
        || stored.expected_checkpoint_sequence != advance.expected_checkpoint_sequence()
        || stored.activation_fence != advance.activation_fence()
        || stored.clock_high_water != advance.clock_high_water()
    {
        return Err(FrostStoreError::Conflict(
            "durable rotation stage differs from verified input",
        ));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::signer_burn_tombstones;

    #[test]
    fn burn_tombstones_are_unique_per_group_session() {
        let burned = signer_burn_tombstones([
            ("session-a".to_string(), "burned".to_string()),
            ("session-a".to_string(), "burned".to_string()),
            ("session-b".to_string(), "completed".to_string()),
            ("session-b".to_string(), "completed".to_string()),
            ("session-c".to_string(), "completed".to_string()),
            ("session-c".to_string(), "burned".to_string()),
        ])
        .unwrap_or_else(|error| panic!("summarize tombstones: {error}"));
        assert_eq!(burned, ["session-a", "session-c"]);
    }

    #[test]
    fn burn_tombstones_reject_every_live_signer_state() {
        for state in ["prepared", "commitment_published", "share_ready"] {
            assert!(signer_burn_tombstones([
                ("session-a".to_string(), "burned".to_string()),
                ("session-a".to_string(), state.to_string()),
            ])
            .is_err());
        }
    }
}