//! Verifying Markdown as it is read: the Sepolia lookup through the host's //! HTTP, a verdict cache, and the badge added to what Memona is handed. use crate::badge; use crate::sdk::host; use anyhow::{anyhow, bail}; use md_ens_signature::document::{body_digest, read_signature}; use md_ens_signature::ens::EthCall; use md_ens_signature::verify::{EthCaller, Verdict, verify}; use std::cell::RefCell; use std::future::Future; use std::pin::pin; use std::task::{Context, Poll, Waker}; use std::time::{Duration, Instant}; /// How long a verdict is reused, so an editor that rereads a file every few /// seconds asks Sepolia at most twice a minute about it. const VERDICT_TTL: Duration = Duration::from_secs(30); /// The largest JSON-RPC answer read. A `findOwner` answer is one word. const MAX_RPC_ANSWER: usize = 64 * 1024; /// The most bytes asked of the host per pull. const RPC_CHUNK: u32 = 64 * 1024; /// The largest file verified. A first check hashes the body twice, at about /// 100 WebAssembly instructions a byte, and recovers a key for about 8.5 /// million more, all inside Memona's 20 million per call; past this size the /// call would run out and the file would not open at all. const MAX_CHECKED: usize = 40 * 1024; /// What a cached verdict belongs to: the signed body and the claim on it. /// The trusted parent is fixed per connection, so it needs no place here. type Claim = ([u8; 32], String, String); /// One connection's view of Sepolia: where to ask, whom to trust, and what it /// answered lately. pub struct Checker { rpc_url: String, trusted_parent: Option, verdicts: RefCell>, } /// Runs a future that never waits. Every [`EthCaller`] here makes a blocking /// host call inside `call`, so the future finishes on its first poll; one /// that did not would be a bug, reported as an error rather than spun on. fn run(future: impl Future) -> anyhow::Result { let mut context = Context::from_waker(Waker::noop()); match pin!(future).poll(&mut context) { Poll::Ready(value) => Ok(value), Poll::Pending => Err(anyhow!("The verification waited")), } } impl Checker { pub fn new(rpc_url: String, trusted_parent: Option) -> Self { Self { rpc_url, trusted_parent, verdicts: RefCell::new(Vec::new()), } } /// The badge text for a file, from the cache when the same body was signed /// the same way less than [`VERDICT_TTL`] ago. A failed lookup is cached /// too, so a node that is down is not asked again on every read. pub fn badge(&self, markdown: &str, caller: &impl EthCaller) -> String { let Some(fields) = read_signature(markdown) else { return badge::label(&Ok(Verdict::Unsigned)); }; let claim = (body_digest(markdown), fields.signer, fields.signature); let now = Instant::now(); let mut verdicts = self.verdicts.borrow_mut(); verdicts.retain(|(_, at, _)| now.duration_since(*at) < VERDICT_TTL); if let Some((_, _, text)) = verdicts.iter().find(|(key, ..)| *key == claim) { return text.clone(); } let parent = self.trusted_parent.as_deref(); let text = badge::label(&run(verify(markdown, parent, caller)).flatten()); verdicts.push((claim, now, text.clone())); text } /// The file as Memona should see it: the stored bytes with the verdict /// line added. Bytes that are not UTF-8 are not Markdown to verify, and /// pass through unchanged. pub fn decorate(&self, stored: Vec, caller: &impl EthCaller) -> Vec { let text = match String::from_utf8(stored) { Ok(text) => text, Err(error) => return error.into_bytes(), }; let label = if text.len() > MAX_CHECKED { badge::TOO_LARGE.to_owned() } else { self.badge(&text, caller) }; badge::inject(&text, &label).into_bytes() } /// The caller that sends `eth_call` to this connection's RPC address. pub fn rpc(&self) -> Rpc<'_> { Rpc { url: &self.rpc_url } } } /// Lowercase hex with a `0x` prefix, as JSON-RPC spells bytes. fn hex(bytes: &[u8]) -> String { let mut out = String::with_capacity(2 + bytes.len() * 2); out.push_str("0x"); for byte in bytes { out.push_str(&format!("{byte:02x}")); } out } /// The JSON-RPC request body for one `eth_call` against the latest block. pub fn request_body(call: &EthCall) -> String { format!( concat!( r#"{{"jsonrpc":"2.0","id":1,"method":"eth_call","#, r#""params":[{{"to":"{}","data":"{}"}},"latest"]}}"#, ), hex(call.to.as_slice()), hex(&call.data), ) } /// Reads the bytes of a JSON-RPC answer's `result`. An answer carrying /// `error`, or no hex `result`, is an error. The answer is one flat object /// from a node, so finding the key and its string is enough. pub fn result_bytes(answer: &str) -> anyhow::Result> { let Some(start) = answer.find(r#""result""#) else { bail!("The node answered without a result"); }; let rest = answer[start + 8..].trim_start(); let rest = rest.strip_prefix(':').unwrap_or(rest).trim_start(); let digits = rest .strip_prefix("\"0x") .and_then(|rest| rest.split('"').next()) .ok_or_else(|| anyhow!("The node's result is not hex"))?; if digits.len() % 2 != 0 { bail!("The node's result has an odd length"); } (0..digits.len()) .step_by(2) .map(|index| { u8::from_str_radix(&digits[index..index + 2], 16) .map_err(|_| anyhow!("The node's result is not hex")) }) .collect() } /// Sends `eth_call` through the host's `http`, which blocks until the node /// answers, so [`EthCaller::call`] never waits. pub struct Rpc<'a> { url: &'a str, } impl EthCaller for Rpc<'_> { async fn call(&self, call: &EthCall) -> anyhow::Result> { let failed = |_| anyhow!("The Sepolia node could not be reached"); let exchange = host::http(&host::Request { method: host::Method::Post, url: self.url.to_owned(), headers: vec![host::Header { name: "content-type".to_owned(), value: "application/json".to_owned(), }], }) .map_err(failed)?; exchange .push(request_body(call).as_bytes()) .map_err(failed)?; let head = exchange.respond().map_err(failed)?; if !(200..300).contains(&head.status) { bail!("The Sepolia node answered {}", head.status); } let mut answer = Vec::new(); loop { let chunk = exchange.pull(RPC_CHUNK).map_err(failed)?; if chunk.is_empty() { break; } answer.extend_from_slice(&chunk); if answer.len() > MAX_RPC_ANSWER { bail!("The Sepolia node's answer is too long"); } } result_bytes(&String::from_utf8_lossy(&answer)) } } #[cfg(test)] mod tests { use super::{Checker, request_body, result_bytes}; use alloy_primitives::Address; use anyhow::bail; use md_ens_signature::document::{ SignatureFields, body_digest, write_signature, }; use md_ens_signature::ens::{EthCall, find_owner_call}; use md_ens_signature::signature::{address_of, sign}; use md_ens_signature::verify::EthCaller; use std::cell::Cell; const KEY: [u8; 32] = [7; 32]; const BODY: &str = "---\ntitle: Plan\n---\nShip it.\n"; /// A Sepolia that knows one owner per name, or none, and counts questions. struct FakeNode { owners: Vec<(&'static str, Address)>, down: bool, asked: Cell, } impl FakeNode { fn new(owners: Vec<(&'static str, Address)>) -> Self { Self { owners, down: false, asked: Cell::new(0), } } } impl EthCaller for FakeNode { async fn call(&self, call: &EthCall) -> anyhow::Result> { self.asked.set(self.asked.get() + 1); if self.down { bail!("connection refused"); } let mut word = vec![0; 32]; for (name, owner) in &self.owners { if find_owner_call(name)?.data == call.data { word[12..].copy_from_slice(owner.as_slice()); } } Ok(word) } } fn signed(signer: &str, body: &str) -> anyhow::Result { let fields = SignatureFields { signer: signer.to_owned(), signature: sign(&KEY, signer, &body_digest(body))?, }; Ok(write_signature(body, &fields)) } fn shown(checker: &Checker, file: &str, node: &FakeNode) -> String { let bytes = checker.decorate(file.as_bytes().to_vec(), node); String::from_utf8_lossy(&bytes).into_owned() } #[test] fn each_verdict_lands_in_the_frontmatter() -> anyhow::Result<()> { let owner = address_of(&KEY)?; let node = FakeNode::new(vec![("bob.team.eth", owner), ("eve.x.eth", owner)]); let checker = Checker::new(String::new(), Some("team.eth".to_owned())); let signed_file = signed("bob.team.eth", BODY)?; let cases = [ (BODY.to_owned(), "Unsigned"), (signed_file.clone(), "Verified: bob.team.eth"), ( signed_file.replace("Ship it.", "Ship it later."), "Tampered: signed as bob.team.eth", ), ( signed("carol.team.eth", BODY)?, "Unauthorized: carol.team.eth is not registered", ), ( signed("eve.x.eth", BODY)?, "Unauthorized: eve.x.eth is not a member of team.eth", ), ]; for (file, badge) in cases { let expected = format!("---\nens-verification: \"{badge}\"\n"); assert!( shown(&checker, &file, &node).starts_with(&expected), "{badge}" ); } Ok(()) } #[test] fn a_node_that_is_down_is_named_not_blamed() -> anyhow::Result<()> { let mut node = FakeNode::new(Vec::new()); node.down = true; let checker = Checker::new(String::new(), None); let file = signed("bob.team.eth", BODY)?; let expected = "---\nens-verification: \"Not checked: Sepolia unreachable\""; assert!(shown(&checker, &file, &node).starts_with(expected)); // The failure is remembered for a while rather than retried per read. shown(&checker, &file, &node); assert_eq!(node.asked.get(), 1); Ok(()) } #[test] fn a_verdict_is_reused_only_for_the_same_signed_body() -> anyhow::Result<()> { let node = FakeNode::new(vec![("bob.team.eth", address_of(&KEY)?)]); let checker = Checker::new(String::new(), None); let file = signed("bob.team.eth", BODY)?; for _ in 0..3 { shown(&checker, &file, &node); } assert_eq!(node.asked.get(), 1); // Other frontmatter is not signed, so it shares the verdict. shown( &checker, &file.replace("title: Plan", "title: Other"), &node, ); assert_eq!(node.asked.get(), 1); shown(&checker, &signed("bob.team.eth", "New body")?, &node); assert_eq!(node.asked.get(), 2); Ok(()) } #[test] fn a_file_too_large_to_hash_is_not_checked() -> anyhow::Result<()> { let node = FakeNode::new(Vec::new()); let checker = Checker::new(String::new(), None); let body = "x".repeat(super::MAX_CHECKED); let file = signed("bob.team.eth", &body)?; let expected = "---\nens-verification: \"Not checked: file too large\""; assert!(shown(&checker, &file, &node).starts_with(expected)); assert_eq!(node.asked.get(), 0); Ok(()) } #[test] fn bytes_that_are_not_text_pass_through() { let node = FakeNode::new(Vec::new()); let checker = Checker::new(String::new(), None); assert_eq!(checker.decorate(vec![0xff, 0x00], &node), vec![0xff, 0x00]); assert_eq!(node.asked.get(), 0); } #[test] fn the_rpc_request_and_answer_are_json_rpc() -> anyhow::Result<()> { let call = find_owner_call("mdsig91205.eth")?; let body = request_body(&call); assert!(body.starts_with(concat!( r#"{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"#, r#""0x85edf8b6b7d4211e2b07aa687506b746357b92cf","data":"0x97ad3b3b"#, ))); assert!(body.ends_with(r#""},"latest"]}"#)); let answer = r#"{"jsonrpc":"2.0","id":1,"result": "0x00ff10"}"#; assert_eq!(result_bytes(answer)?, vec![0x00, 0xff, 0x10]); assert_eq!(result_bytes(r#"{"result":"0x"}"#)?, Vec::::new()); for bad in [ r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32000}}"#, r#"{"result":"0x0"}"#, r#"{"result":"0xzz"}"#, r#"{"result":null}"#, ] { assert!(result_bytes(bad).is_err(), "{bad}"); } Ok(()) } }