use std::collections::BTreeMap;
use std::fmt::Debug;
use std::sync::Mutex;
use async_trait::async_trait;
use crate::core::{CheckpointSigner, Digest, KeyId, SignError, merkle};
use super::Checkpoint;
#[derive(Debug, thiserror::Error)]
pub enum WitnessError {
#[error("log '{origin}' shrank from {seen} to {offered} — runs were removed")]
Shrank {
origin: String,
seen: u64,
offered: u64,
},
#[error(
"log '{origin}' at size {offered} does not extend the checkpoint this \
witness cosigned at size {seen} — the history was rewritten or forked"
)]
Forked {
origin: String,
seen: u64,
offered: u64,
},
#[error(
"log '{origin}': the witness is at size {witness_size}; build a consistency proof \
from there and resubmit"
)]
Stale { origin: String, witness_size: u64 },
#[error("log '{origin}': a consistency proof is required to extend size {seen}")]
ProofMissing { origin: String, seen: u64 },
#[error("witness: {0}")]
Unavailable(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cosignature {
pub key_id: KeyId,
pub note_key_id: [u8; 4],
pub signature: Vec<u8>,
}
#[must_use]
pub(crate) fn cosignature_message(timestamp: u64, note_text: &str) -> String {
format!("cosignature/v1\ntime {timestamp}\n{note_text}")
}
#[cfg(feature = "witness-http")]
#[must_use]
pub(crate) fn cosignature_payload(blob: &[u8]) -> Option<(u64, &[u8])> {
if blob.len() != 8 + 64 {
return None;
}
let (stamp, signature) = blob.split_at(8);
let timestamp = u64::from_be_bytes(stamp.try_into().expect("eight bytes"));
Some((timestamp, signature))
}
#[async_trait]
pub trait Witness: Send + Sync + Debug {
async fn cosign(
&self,
checkpoint: &Checkpoint,
old_size: u64,
proof: &[Digest],
) -> Result<Cosignature, WitnessError>;
}
#[derive(Debug, Clone, Copy)]
pub struct WitnessQuorum {
required: usize,
}
impl WitnessQuorum {
pub fn of(required: usize) -> Result<Self, &'static str> {
if required == 0 {
return Err(
"a quorum of zero cosignatures is witnessing that is off, spelled as if \
it were on — omit witnessing instead of declaring an empty one",
);
}
Ok(Self { required })
}
#[must_use]
pub const fn required(&self) -> usize {
self.required
}
}
#[derive(Debug)]
pub struct QuorumOutcome {
pub cosignatures: Vec<Cosignature>,
pub routine: Vec<(usize, WitnessError)>,
pub integrity: Vec<(usize, WitnessError)>,
required: usize,
}
impl QuorumOutcome {
#[must_use]
pub fn met(&self) -> bool {
self.cosignatures.len() >= self.required
}
#[must_use]
pub fn shortfall(&self) -> usize {
self.required.saturating_sub(self.cosignatures.len())
}
#[must_use]
pub fn needs_attention(&self) -> bool {
!self.met() || !self.integrity.is_empty()
}
}
pub async fn cosign_quorum(
store: &dyn super::JournalStore,
checkpoint: &Checkpoint,
witnesses: &[std::sync::Arc<dyn Witness>],
quorum: WitnessQuorum,
) -> Result<QuorumOutcome, crate::core::StoreError> {
let mut outcome = QuorumOutcome {
cosignatures: Vec::new(),
routine: Vec::new(),
integrity: Vec::new(),
required: quorum.required(),
};
for (index, witness) in witnesses.iter().enumerate() {
let first = witness.cosign(checkpoint, 0, &[]).await;
let result = match first {
Err(WitnessError::Stale { witness_size, .. }) => {
let proof = if witness_size <= checkpoint.size {
store.consistency_proof(witness_size).await?
} else {
Vec::new()
};
witness.cosign(checkpoint, witness_size, &proof).await
}
other => other,
};
match result {
Ok(cosignature) => outcome.cosignatures.push(cosignature),
Err(e @ (WitnessError::Forked { .. } | WitnessError::Shrank { .. })) => {
outcome.integrity.push((index, e));
}
Err(e) => outcome.routine.push((index, e)),
}
}
Ok(outcome)
}
#[derive(Debug)]
pub struct MemoryWitness {
signer: std::sync::Arc<dyn CheckpointSigner>,
seen: Mutex<BTreeMap<String, (u64, Digest)>>,
}
impl MemoryWitness {
#[must_use]
pub fn new(signer: std::sync::Arc<dyn CheckpointSigner>) -> Self {
Self {
signer,
seen: Mutex::new(BTreeMap::new()),
}
}
#[must_use]
pub fn last_seen(&self, origin: &str) -> Option<(u64, Digest)> {
self.seen
.lock()
.expect("witness mutex")
.get(origin)
.copied()
}
}
#[async_trait]
impl Witness for MemoryWitness {
async fn cosign(
&self,
checkpoint: &Checkpoint,
old_size: u64,
proof: &[Digest],
) -> Result<Cosignature, WitnessError> {
{
let mut seen = self
.seen
.lock()
.map_err(|_| WitnessError::Unavailable("witness mutex poisoned".into()))?;
let remembered_size = seen.get(&checkpoint.origin).map_or(0, |(size, _)| *size);
if old_size != remembered_size {
return Err(WitnessError::Stale {
origin: checkpoint.origin.clone(),
witness_size: remembered_size,
});
}
if !checkpoint.is_coherent() {
return Err(WitnessError::Unavailable(format!(
"log '{}': the checkpoint claims size {} with a root the empty tree \
does not have — refused rather than remembered, because a witness \
holds every later checkpoint to its first one",
checkpoint.origin, checkpoint.size
)));
}
if let Some((remembered, old_root)) = seen.get(&checkpoint.origin).copied() {
let old_size = remembered;
if checkpoint.size < old_size {
return Err(WitnessError::Shrank {
origin: checkpoint.origin.clone(),
seen: old_size,
offered: checkpoint.size,
});
}
let unchanged = checkpoint.size == old_size && checkpoint.root == old_root;
if !unchanged {
if proof.is_empty() && old_size > 0 && checkpoint.size > old_size {
return Err(WitnessError::ProofMissing {
origin: checkpoint.origin.clone(),
seen: old_size,
});
}
let old = usize::try_from(old_size).unwrap_or(usize::MAX);
let new = usize::try_from(checkpoint.size).unwrap_or(usize::MAX);
if !merkle::verify_consistency(old, &old_root, new, &checkpoint.root, proof) {
return Err(WitnessError::Forked {
origin: checkpoint.origin.clone(),
seen: old_size,
offered: checkpoint.size,
});
}
}
}
seen.insert(
checkpoint.origin.clone(),
(checkpoint.size, checkpoint.root),
);
}
let message = cosignature_message(0, &checkpoint.to_note());
let signature = self
.signer
.sign(message.as_bytes())
.await
.map_err(|e| match e {
SignError::Unavailable(d) => WitnessError::Unavailable(d),
SignError::Refused { key_id, detail } => {
WitnessError::Unavailable(format!("key '{key_id}' refused: {detail}"))
}
})?;
let mut payload = Vec::with_capacity(8 + signature.len());
payload.extend_from_slice(&0u64.to_be_bytes());
payload.extend_from_slice(&signature);
Ok(Cosignature {
key_id: self.signer.key_id(),
note_key_id: [0; 4],
signature: payload,
})
}
}