use async_trait::async_trait;
use crate::core::Digest;
use super::Checkpoint;
use super::note::b64;
use super::note::{NoteSignature, SignedNote};
use super::witness::{Cosignature, Witness, WitnessError};
#[derive(Debug, Clone)]
pub struct TrustedWitness {
name: String,
public_key: [u8; 32],
note_key_id: [u8; 4],
}
impl TrustedWitness {
#[must_use]
pub fn ed25519(name: impl Into<String>, public_key: [u8; 32]) -> Self {
let name = name.into();
let note_key_id = super::note::key_id(&name, 0x01, &public_key);
Self {
name,
public_key,
note_key_id,
}
}
#[must_use]
pub const fn note_key_id(&self) -> [u8; 4] {
self.note_key_id
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
}
#[derive(Debug, Clone)]
pub struct HttpWitness {
http: reqwest::Client,
prefix: String,
trusted: Vec<TrustedWitness>,
log_signature: NoteSignature,
}
impl HttpWitness {
pub fn new(
prefix: impl Into<String>,
log_signature: NoteSignature,
trusted: Vec<TrustedWitness>,
) -> Result<Self, WitnessError> {
if trusted.is_empty() {
return Err(WitnessError::Unavailable(
"a witness needs at least one trusted key: a cosignature nobody can \
verify is a 200 with a base64 string in it, and counting those toward \
a quorum is the failure witnessing exists to rule out"
.into(),
));
}
SignedNote::validate_name(&log_signature.name)
.map_err(|e| WitnessError::Unavailable(e.to_string()))?;
let http = reqwest::Client::builder().build().map_err(|e| {
WitnessError::Unavailable(format!("could not build an HTTP client: {e}"))
})?;
Ok(Self {
http,
prefix: prefix.into().trim_end_matches('/').to_owned(),
trusted,
log_signature,
})
}
fn body(&self, checkpoint: &Checkpoint, old_size: u64, proof: &[Digest]) -> String {
let mut out = format!("old {old_size}\n");
for hash in proof {
out.push_str(&b64(hash.as_bytes()));
out.push('\n');
}
out.push('\n');
let note = SignedNote::new(checkpoint.to_note())
.and_then(|n| n.with_signature(self.log_signature.clone()))
.unwrap_or_else(|e| {
unreachable!("a checkpoint note is a valid body and `new` checked the name: {e}")
});
out.push_str(¬e.to_wire());
out
}
}
#[async_trait]
impl Witness for HttpWitness {
async fn cosign(
&self,
checkpoint: &Checkpoint,
old_size: u64,
proof: &[Digest],
) -> Result<Cosignature, WitnessError> {
let url = format!("{}/add-checkpoint", self.prefix);
let body = self.body(checkpoint, old_size, proof);
let response = self
.http
.post(&url)
.body(body.clone())
.send()
.await
.map_err(|e| WitnessError::Unavailable(format!("{url}: {e}")))?;
let status = response.status().as_u16();
let text = response
.text()
.await
.map_err(|e| WitnessError::Unavailable(format!("{url}: reading the reply: {e}")))?;
match status {
200 => {
let submitted = body
.split_once("\n\n")
.map_or(body.as_str(), |(_, note)| note);
verify_cosignature(&text, &checkpoint.origin, submitted, &self.trusted)
}
409 => match text.trim().parse::<u64>() {
Ok(witness_size) => Err(WitnessError::Stale {
origin: checkpoint.origin.clone(),
witness_size,
}),
Err(_) => Err(WitnessError::Unavailable(format!(
"{url}: the witness answered 409 (stale) but its body is not a tree size, \
so there is nothing to build a proof from — refused rather than read as \
size 0, which would resubmit a proof the witness rejects as a fork"
))),
},
400 if old_size > checkpoint.size => Err(WitnessError::Shrank {
origin: checkpoint.origin.clone(),
seen: old_size,
offered: checkpoint.size,
}),
400 => Err(WitnessError::Unavailable(format!(
"{url}: the witness answered 400 (old size exceeds checkpoint size) for a \
request whose old size {old_size} does not exceed {} — an off-spec reply, \
refused rather than read as a shrink it does not evidence",
checkpoint.size
))),
422 => Err(WitnessError::Forked {
origin: checkpoint.origin.clone(),
seen: old_size,
offered: checkpoint.size,
}),
403 => Err(WitnessError::Unavailable(format!(
"{url}: the witness does not trust the key that signed this checkpoint — it \
cosigns for logs it recognises, so the log's key must be registered with the \
operator first"
))),
404 => Err(WitnessError::Unavailable(format!(
"{url}: the witness does not know the origin '{}'",
checkpoint.origin
))),
other => Err(WitnessError::Unavailable(format!(
"{url}: unexpected status {other}: {}",
text.trim()
))),
}
}
}
fn verify_cosignature(
body: &str,
origin: &str,
submitted_note: &str,
trusted: &[TrustedWitness],
) -> Result<Cosignature, WitnessError> {
use ed25519_dalek::{Signature, Verifier as _, VerifyingKey};
let framed = format!("witness\n\n{body}");
let note = SignedNote::parse(&framed).map_err(|e| {
WitnessError::Unavailable(format!("log '{origin}': unreadable cosignature: {e}"))
})?;
if note.signatures.is_empty() {
return Err(WitnessError::Unavailable(format!(
"log '{origin}': the witness answered 200 with no signature, which is not a \
cosignature however encouraging the status code is"
)));
}
for line in ¬e.signatures {
let Some(key) = trusted
.iter()
.find(|k| k.name == line.name && k.note_key_id == line.key_id)
else {
continue;
};
let Ok(verifying) = VerifyingKey::from_bytes(&key.public_key) else {
continue;
};
let Ok(signature) = Signature::from_slice(&line.signature) else {
continue;
};
if verifying
.verify(submitted_note.as_bytes(), &signature)
.is_ok()
{
return Ok(Cosignature {
key_id: key.name.clone(),
note_key_id: key.note_key_id,
signature: line.signature.clone(),
});
}
}
Err(WitnessError::Unavailable(format!(
"log '{origin}': the witness answered 200, and none of its {} signature line(s) \
verified against a trusted key over the checkpoint that was submitted",
note.signatures.len()
)))
}