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, cosignature_message, cosignature_payload,
};
#[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, 0x04, &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()
.timeout(Self::TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.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,
})
}
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
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"
)));
}
let note_text = submitted_note.split_once("\n\n").map_or_else(
|| submitted_note.to_owned(),
|(text, _)| format!("{text}\n"),
);
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 Some((timestamp, sig)) = cosignature_payload(&line.signature) else {
continue;
};
let Ok(signature) = Signature::from_slice(sig) else {
continue;
};
let message = cosignature_message(timestamp, ¬e_text);
if verifying.verify(message.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()
)))
}
#[cfg(test)]
mod codec_tests {
use super::*;
const EXAMPLE_NOTE: &str =
"example.com/behind-the-sofa\n20852163\nCsUYapGGPo4dkMgIAUqom/Xajj7h2fB2MPA3j2jxq2I=\n";
const EXAMPLE_LINE_PAYLOAD: &str = "jWbPPwAAAABkGFDLEZMHwSRaJNiIDoe9DYn/zXcrtPHeolMI5OWXEhZCB9dlrDJsX3b2oyin1nPZqhf5nNo0xUe+mbIUBkBIfZ+qnA==";
#[test]
fn the_spec_worked_example_is_reproduced() {
assert_eq!(
cosignature_message(1_679_315_147, EXAMPLE_NOTE),
"cosignature/v1\ntime 1679315147\n\
example.com/behind-the-sofa\n20852163\n\
CsUYapGGPo4dkMgIAUqom/Xajj7h2fB2MPA3j2jxq2I=\n",
"the message is two newline-terminated lines followed by the note \
body, signature lines excluded"
);
let payload = super::super::note::unb64(EXAMPLE_LINE_PAYLOAD)
.expect("the spec's example line is valid base64");
assert_eq!(payload.len(), 4 + 8 + 64);
assert_eq!(
payload[..4],
[0x8d, 0x66, 0xcf, 0x3f],
"the four-byte key id of the example witness"
);
let (timestamp, sig) =
cosignature_payload(&payload[4..]).expect("eight bytes of timestamp, then a signature");
assert_eq!(
timestamp, 1_679_315_147,
"the timestamp is big-endian and sits before the signature"
);
assert_eq!(sig.len(), 64);
}
#[test]
fn a_payload_without_a_timestamp_is_not_a_cosignature() {
assert!(cosignature_payload(&[0u8; 64]).is_none());
assert!(cosignature_payload(&[0u8; 73]).is_none());
assert!(cosignature_payload(&[]).is_none());
assert!(cosignature_payload(&[0u8; 72]).is_some());
}
}