365 lines
12 KiB
Rust
365 lines
12 KiB
Rust
//! 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<String>,
|
|
verdicts: RefCell<Vec<(Claim, Instant, String)>>,
|
|
}
|
|
|
|
/// 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<T>(future: impl Future<Output = T>) -> anyhow::Result<T> {
|
|
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<String>) -> 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<u8>, caller: &impl EthCaller) -> Vec<u8> {
|
|
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<Vec<u8>> {
|
|
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<Vec<u8>> {
|
|
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<usize>,
|
|
}
|
|
|
|
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<Vec<u8>> {
|
|
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<String> {
|
|
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::<u8>::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(())
|
|
}
|
|
}
|