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, CosignedCheckpoint, 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 LogKey {
name: String,
note_key_id: [u8; 4],
signer: std::sync::Arc<dyn crate::core::CheckpointSigner>,
}
impl LogKey {
pub fn ed25519(
name: impl Into<String>,
public_key: [u8; 32],
signer: std::sync::Arc<dyn crate::core::CheckpointSigner>,
) -> Result<Self, WitnessError> {
let name = name.into();
SignedNote::validate_name(&name).map_err(|e| WitnessError::Unavailable(e.to_string()))?;
let note_key_id = super::note::key_id(&name, 0x01, &public_key);
Ok(Self {
name,
note_key_id,
signer,
})
}
async fn sign(&self, body: &str) -> Result<NoteSignature, WitnessError> {
let signature = self.signer.sign(body.as_bytes()).await?;
Ok(NoteSignature {
name: self.name.clone(),
key_id: self.note_key_id,
signature,
})
}
}
#[derive(Debug, Clone)]
pub struct HttpWitness {
http: reqwest::Client,
prefix: String,
monitoring: String,
trusted: Vec<TrustedWitness>,
log: LogKey,
}
impl HttpWitness {
pub fn new(
prefix: impl Into<String>,
log: LogKey,
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(),
));
}
let http = crate::netguard::guarded_client(crate::netguard::Reach::Configured)
.timeout(Self::TIMEOUT)
.build()
.map_err(|e| {
WitnessError::Unavailable(format!("could not build an HTTP client: {e}"))
})?;
let prefix = prefix.into().trim_end_matches('/').to_owned();
Ok(Self {
http,
monitoring: prefix.clone(),
prefix,
trusted,
log,
})
}
pub fn reader(&self) -> Result<WitnessReader, WitnessError> {
WitnessReader::new(&self.monitoring, self.trusted.clone())
}
#[must_use]
pub fn monitoring_at(mut self, prefix: impl Into<String>) -> Self {
prefix
.into()
.trim_end_matches('/')
.clone_into(&mut self.monitoring);
self
}
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
async fn body(
&self,
checkpoint: &Checkpoint,
old_size: u64,
proof: &[Digest],
) -> Result<String, WitnessError> {
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 body = checkpoint.to_note();
let signature = self.log.sign(&body).await?;
let note = SignedNote::new(body)
.and_then(|n| n.with_signature(signature))
.map_err(|e| {
WitnessError::Unavailable(format!("the checkpoint note is not submittable: {e}"))
})?;
out.push_str(¬e.to_wire());
Ok(out)
}
fn coherent(
checkpoint: &Checkpoint,
old_size: u64,
proof: &[Digest],
) -> Result<(), WitnessError> {
if !checkpoint.is_coherent() {
return Err(WitnessError::Unavailable(format!(
"log '{}': a checkpoint of size 0 must carry the empty tree's root, and \
this one does not — a witness answers 422 for it, which is the same \
status it uses for a history that does not extend",
checkpoint.origin
)));
}
if old_size == 0 && !proof.is_empty() {
return Err(WitnessError::Unavailable(format!(
"log '{}': a submission from size 0 must carry no consistency proof, \
because the empty tree is consistent with every tree — {} proof \
line(s) were built, and a witness answers 422 for them",
checkpoint.origin,
proof.len()
)));
}
Ok(())
}
}
#[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);
Self::coherent(checkpoint, old_size, proof)?;
let body = self.body(checkpoint, old_size, proof).await?;
let response = self
.http
.post(&url)
.body(body.clone())
.send()
.await
.map_err(|e| WitnessError::Unavailable(crate::netguard::transport_text(&e)))?;
let status = response.status().as_u16();
let text = crate::netguard::intake::read_text(response, crate::netguard::intake::METADATA)
.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 if old_size == checkpoint.size => Err(WitnessError::Forked {
origin: checkpoint.origin.clone(),
seen: old_size,
offered: checkpoint.size,
}),
422 => Err(WitnessError::Inconsistent {
origin: checkpoint.origin.clone(),
old_size,
offered: checkpoint.size,
}),
403 => Err(WitnessError::Unavailable(format!(
"{url}: the witness would not attribute this checkpoint to a key it \
trusts for origin '{}' — either this log's key is not registered with \
that operator, or the signature the key '{}' produced does not verify \
under the public half they hold",
checkpoint.origin, self.log.name,
))),
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_line(
line: &NoteSignature,
note_text: &str,
trusted: &[TrustedWitness],
) -> Option<Cosignature> {
use ed25519_dalek::{Signature, Verifier as _, VerifyingKey};
let key = trusted
.iter()
.find(|k| k.name == line.name && k.note_key_id == line.key_id)?;
let verifying = VerifyingKey::from_bytes(&key.public_key).ok()?;
let (timestamp, sig) = cosignature_payload(&line.signature)?;
if timestamp == 0 {
return None;
}
let signature = Signature::from_slice(sig).ok()?;
let message = cosignature_message(timestamp, note_text);
verifying.verify(message.as_bytes(), &signature).ok()?;
Some(Cosignature {
key_id: key.name.clone(),
note_key_id: key.note_key_id,
signature: line.signature.clone(),
})
}
fn verify_cosignature(
body: &str,
origin: &str,
submitted_note: &str,
trusted: &[TrustedWitness],
) -> Result<Cosignature, WitnessError> {
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 {
if let Some(cosignature) = verify_line(line, ¬e_text, trusted) {
return Ok(cosignature);
}
}
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());
}
#[test]
fn a_timestamp_past_two_to_the_sixty_three_is_not_a_cosignature() {
let payload = |stamp: u64| {
let mut blob = stamp.to_be_bytes().to_vec();
blob.extend_from_slice(&[0u8; 64]);
blob
};
assert!(cosignature_payload(&payload(1 << 63)).is_none());
assert!(cosignature_payload(&payload(u64::MAX)).is_none());
assert_eq!(
cosignature_payload(&payload(i64::MAX.cast_unsigned())).map(|(t, _)| t),
Some(i64::MAX.cast_unsigned()),
"the largest timestamp the spec allows is still one"
);
}
}
#[derive(Debug, Clone)]
pub struct WitnessReader {
http: reqwest::Client,
monitoring: String,
trusted: Vec<TrustedWitness>,
}
impl WitnessReader {
pub fn new(
prefix: impl Into<String>,
trusted: Vec<TrustedWitness>,
) -> Result<Self, WitnessError> {
if trusted.is_empty() {
return Err(WitnessError::Unavailable(
"a witness reader needs at least one trusted key: without one it can \
only report what a URL served, and an auditor would be holding a \
stranger's checkpoint as an independent anchor"
.into(),
));
}
let http = crate::netguard::guarded_client(crate::netguard::Reach::Configured)
.timeout(HttpWitness::TIMEOUT)
.build()
.map_err(|e| {
WitnessError::Unavailable(format!("could not build an HTTP client: {e}"))
})?;
Ok(Self {
http,
monitoring: prefix.into().trim_end_matches('/').to_owned(),
trusted,
})
}
pub async fn latest(&self, origin: &str) -> Result<Option<CosignedCheckpoint>, WitnessError> {
use sha2::{Digest as _, Sha256};
let hash = hex::encode(Sha256::digest(origin.as_bytes()));
let url = format!("{}/{hash}/checkpoint", self.monitoring);
let response = self
.http
.get(&url)
.send()
.await
.map_err(|e| WitnessError::Unavailable(crate::netguard::transport_text(&e)))?;
let status = response.status().as_u16();
let text = crate::netguard::intake::read_text(response, crate::netguard::intake::METADATA)
.await
.map_err(|e| WitnessError::Unavailable(format!("{url}: reading the reply: {e}")))?;
if status == 404 {
return Ok(None);
}
if status != 200 {
return Err(WitnessError::Unavailable(format!(
"{url}: unexpected status {status}: {}",
text.trim()
)));
}
let note = SignedNote::parse(&text).map_err(|e| {
WitnessError::Unavailable(format!("{url}: unreadable cosigned checkpoint: {e}"))
})?;
let checkpoint = Checkpoint::from_note(¬e.text)
.map_err(|e| WitnessError::Unavailable(format!("{url}: {e}")))?;
if checkpoint.origin != origin {
return Err(WitnessError::Unavailable(format!(
"{url}: the witness answered with a checkpoint for log \
'{}' rather than '{origin}'",
checkpoint.origin
)));
}
let mut cosignatures = Vec::new();
for line in ¬e.signatures {
if let Some(cosignature) = verify_line(line, ¬e.text, &self.trusted) {
cosignatures.push(cosignature);
}
}
if cosignatures.is_empty() {
return Err(WitnessError::Unavailable(format!(
"{url}: the witness answered a checkpoint carrying {} signature line(s) \
and none of them verifies under a key this deployment trusts — a \
checkpoint nobody independent signed is the plane's own claim wearing \
a witness's URL",
note.signatures.len()
)));
}
Ok(Some(CosignedCheckpoint {
checkpoint,
cosignatures,
}))
}
}