diff --git a/Cargo.lock b/Cargo.lock index f6bda88..573b910 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -490,6 +490,8 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" name = "memona-plugin-signature" version = "0.1.0" dependencies = [ + "alloy-primitives", + "anyhow", "md-ens-signature", "quick-xml", "wit-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 433272a..2cfb118 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,12 @@ crate-type = ["cdylib", "rlib"] [dependencies] wit-bindgen = "=0.62.0" quick-xml = { version = "0.37", default-features = false } +anyhow = "1.0.104" md-ens-signature = { git = "https://github.com/capsulizers/md-ens-signature", rev = "86878b144154c894948c595d2f0d66fbbd36ae4b" } +[dev-dependencies] +alloy-primitives = { version = "1.7.3", default-features = false } + [lints.clippy] panic = "deny" unwrap_used = "deny" diff --git a/README.md b/README.md index 5ba34ad..bc2ce46 100644 --- a/README.md +++ b/README.md @@ -29,14 +29,22 @@ contents is refused rather than removed whole. ## Signing -Saving a `.md` file holds it whole, up to 8 MiB, and writes it once finished, +Saving a `.md` file holds it whole, up to 96 KiB, and writes it once finished, with `signer` and `signature` set in its frontmatter; every other file streams straight through. The signature is EIP-191 `personal_sign` over the signer name and the body's SHA-256, made with the configured key; other frontmatter and the body stay byte for byte. A key that is not 32 bytes of hex refuses the -connection, and a `.md` file that is not UTF-8 refuses the save. Reading passes -every file through unchanged. The RPC address is for verifying signers, which a -later version adds to reads. +connection, and a `.md` file that is not UTF-8 refuses the save. + +## Verifying + +Reading a `.md` file of up to 40 KiB checks its signature against ENSv2 on +Sepolia through the RPC address and adds an `ens-verification` line to the +frontmatter Memona receives, which Memona shows as a badge; a larger one up to +1 MiB is marked as not checked. Both limits keep hashing and key recovery +inside the instruction budget Memona gives each call. The line never +reaches the server: a save drops it before signing, and one stored on the +server is replaced. Verdicts are reused for 30 seconds per signed body. ## Building diff --git a/manifest.json b/manifest.json index 690981f..c730656 100644 --- a/manifest.json +++ b/manifest.json @@ -55,6 +55,13 @@ "kind": "text", "required": false, "default": "https://ethereum-sepolia-rpc.publicnode.com" + }, + { + "key": "trustedParent", + "label": "Trusted parent name", + "description": "Only count signers that are this ENS name or one of its subnames, for example team.eth", + "kind": "text", + "required": false } ] } diff --git a/src/badge.rs b/src/badge.rs new file mode 100644 index 0000000..af3f969 --- /dev/null +++ b/src/badge.rs @@ -0,0 +1,287 @@ +//! The one frontmatter line that tells Memona what verification found. +//! +//! Memona shows every frontmatter key as a badge, so the verdict travels as a +//! line the plugin adds to the bytes a read returns and removes from the bytes +//! a write receives. It never reaches the server, and a line the server holds +//! is never trusted: it is removed before the plugin adds its own. + +use md_ens_signature::verify::{Reason, Verdict}; +use std::borrow::Cow; + +/// The frontmatter key the verdict is shown under. +pub const KEY: &str = "ens-verification"; +/// The badge for a file too large to hash within one host call. +pub const TOO_LARGE: &str = "Not checked: file too large"; +/// The line that opens and closes a frontmatter block. +const FENCE: &str = "---"; + +/// The badge text for a verdict, or for a lookup that could not be made. +pub fn label(verdict: &anyhow::Result) -> String { + match verdict { + Ok(Verdict::Unsigned) => "Unsigned".to_owned(), + Ok(Verdict::Tampered { signer }) => format!("Tampered: signed as {signer}"), + Ok(Verdict::Unauthorized { + signer, + reason: Reason::Unregistered, + }) => format!("Unauthorized: {signer} is not registered"), + Ok(Verdict::Unauthorized { + signer, + reason: Reason::NotMember { parent }, + }) => format!("Unauthorized: {signer} is not a member of {parent}"), + Ok(Verdict::Verified { signer, .. }) => format!("Verified: {signer}"), + Err(_) => "Not checked: Sepolia unreachable".to_owned(), + } +} + +/// Returns a line without its trailing `\n` or `\r\n`. +fn without_newline(line: &str) -> &str { + let line = line.strip_suffix('\n').unwrap_or(line); + line.strip_suffix('\r').unwrap_or(line) +} + +/// Where a closed frontmatter block sits: the byte after the opening fence +/// line, the byte where the closing fence line starts, and the byte after it. +fn block(markdown: &str) -> Option<(usize, usize, usize)> { + let mut lines = markdown.split_inclusive('\n'); + let first = lines.next()?; + if !first.ends_with('\n') || without_newline(first) != FENCE { + return None; + } + let mut offset = first.len(); + for line in lines { + if without_newline(line) == FENCE { + return Some((first.len(), offset, offset + line.len())); + } + offset += line.len(); + } + None +} + +/// Whether a frontmatter line sets the verdict key at the top level. +fn is_badge_line(line: &str) -> bool { + line + .strip_prefix(KEY) + .is_some_and(|rest| rest.trim_start_matches([' ', '\t']).starts_with(':')) +} + +/// Whether a line continues the value above it, as a folded or block YAML +/// value does, rather than starting a key of its own. +fn is_continuation(line: &str) -> bool { + line.starts_with([' ', '\t']) && !without_newline(line).trim().is_empty() +} + +/// Removes every verdict line, and whatever continues it, from the +/// frontmatter. A block left holding nothing is removed whole, since the +/// plugin made it; the body is never touched, so its digest stays the same. +pub fn strip(markdown: &str) -> Cow<'_, str> { + let Some((inner, close, end)) = block(markdown) else { + return Cow::Borrowed(markdown); + }; + let mut kept = String::with_capacity(close - inner); + let mut removed = false; + let mut skipping = false; + for line in markdown[inner..close].split_inclusive('\n') { + if is_badge_line(line) { + removed = true; + skipping = true; + continue; + } + if skipping && is_continuation(line) { + continue; + } + skipping = false; + kept.push_str(line); + } + if !removed { + return Cow::Borrowed(markdown); + } + if kept.is_empty() { + return Cow::Borrowed(&markdown[end..]); + } + Cow::Owned(format!( + "{}{kept}{}", + &markdown[..inner], + &markdown[close..] + )) +} + +/// Quotes a badge for a YAML double-quoted scalar. A signer name comes from +/// the file, so its quotes, backslashes and control characters are escaped +/// or dropped rather than allowed to end the value early. +fn quoted(text: &str) -> String { + let mut out = String::with_capacity(text.len() + 2); + out.push('"'); + for character in text.chars() { + match character { + '"' | '\\' => { + out.push('\\'); + out.push(character); + } + _ if character.is_control() => {} + _ => out.push(character), + } + } + out.push('"'); + out +} + +/// Returns the file with `text` as its only verdict line, first in the +/// frontmatter, so the badge leads the row. A file without frontmatter gets a +/// block holding just that line, which [`strip`] removes again. +/// +/// Only the frontmatter is looked at and the rest is copied once, because a +/// host call has a fixed instruction budget that a large file must fit in. +pub fn inject(markdown: &str, text: &str) -> String { + let markdown = strip(markdown); + // The file's first line ending is the one the added lines follow. + let newline = match markdown.find('\n') { + Some(index) if markdown[..index].ends_with('\r') => "\r\n", + _ => "\n", + }; + let line = format!("{KEY}: {}{newline}", quoted(text)); + let (head, tail) = match block(&markdown) { + Some((inner, _, _)) => markdown.split_at(inner), + None => ("", markdown.as_ref()), + }; + let mut out = String::with_capacity(markdown.len() + line.len() + 10); + if head.is_empty() { + out.push_str(FENCE); + out.push_str(newline); + out.push_str(&line); + out.push_str(FENCE); + out.push_str(newline); + } else { + out.push_str(head); + out.push_str(&line); + } + out.push_str(tail); + out +} + +#[cfg(test)] +mod tests { + use super::{inject, label, strip}; + use alloy_primitives::Address; + use anyhow::anyhow; + use md_ens_signature::document::body_digest; + use md_ens_signature::verify::{Reason, Verdict}; + + #[test] + fn every_verdict_reads_as_its_badge() { + let signer = "bob.team.eth".to_owned(); + let cases = [ + (Ok(Verdict::Unsigned), "Unsigned"), + ( + Ok(Verdict::Tampered { + signer: signer.clone(), + }), + "Tampered: signed as bob.team.eth", + ), + ( + Ok(Verdict::Unauthorized { + signer: signer.clone(), + reason: Reason::Unregistered, + }), + "Unauthorized: bob.team.eth is not registered", + ), + ( + Ok(Verdict::Unauthorized { + signer: signer.clone(), + reason: Reason::NotMember { + parent: "team.eth".to_owned(), + }, + }), + "Unauthorized: bob.team.eth is not a member of team.eth", + ), + ( + Ok(Verdict::Verified { + signer, + address: Address::ZERO, + }), + "Verified: bob.team.eth", + ), + (Err(anyhow!("refused")), "Not checked: Sepolia unreachable"), + ]; + for (verdict, expected) in cases { + assert_eq!(label(&verdict), expected); + } + } + + #[test] + fn the_badge_leads_existing_frontmatter() { + let file = "---\nsigner: a.eth\n---\n# Hi\n"; + assert_eq!( + inject(file, "Verified: a.eth"), + "---\nens-verification: \"Verified: a.eth\"\nsigner: a.eth\n---\n# Hi\n" + ); + } + + #[test] + fn a_file_without_frontmatter_gets_a_block_that_strips_away() { + for file in ["# Hi\n", "", "---\nnot closed\n", "a\r\nb\r\n"] { + let shown = inject(file, "Unsigned"); + assert!(shown.starts_with("---"), "{file:?}"); + assert_eq!(strip(&shown), file); + } + assert_eq!( + inject("a\r\nb", "Unsigned"), + "---\r\nens-verification: \"Unsigned\"\r\n---\r\na\r\nb" + ); + } + + #[test] + fn stripping_restores_the_stored_bytes() { + for file in [ + "---\nsigner: a.eth\nsignature: \"0x12\"\n---\nBody\n", + "---\r\ntitle: T\r\n---\r\nBody\r\n", + "---\n---\nBody", + "# No frontmatter\n", + ] { + let shown = inject(file, "Verified: a.eth"); + assert_ne!(shown, file); + let restored = strip(&shown); + // An empty block the badge filled is dropped, and the body with it + // keeps its digest. + assert_eq!(body_digest(&restored), body_digest(file)); + if file != "---\n---\nBody" { + assert_eq!(restored, file); + } + } + } + + #[test] + fn a_stored_verdict_is_never_trusted() { + let forged = "---\nens-verification: \"Verified: ceo.eth\"\n---\nPay me\n"; + assert_eq!( + inject(forged, "Unsigned"), + "---\nens-verification: \"Unsigned\"\n---\nPay me\n" + ); + } + + #[test] + fn an_edited_or_reformatted_badge_is_still_stripped() { + let edited = concat!( + "---\n", + "title: T\n", + "ens-verification : 'Verified: x'\n", + "ens-verification: >-\n", + " folded\n", + " value\n", + "tags: [a]\n", + "---\n", + "ens-verification: body text stays\n", + ); + assert_eq!( + strip(edited), + "---\ntitle: T\ntags: [a]\n---\nens-verification: body text stays\n" + ); + } + + #[test] + fn a_signer_cannot_break_out_of_the_quotes() { + assert_eq!( + inject("x", "Tampered: signed as a\"b\\c\u{7}.eth"), + "---\nens-verification: \"Tampered: signed as a\\\"b\\\\c.eth\"\n---\nx" + ); + } +} diff --git a/src/check.rs b/src/check.rs new file mode 100644 index 0000000..f82d9ff --- /dev/null +++ b/src/check.rs @@ -0,0 +1,364 @@ +//! 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(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index daea938..051e887 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,15 @@ -//! A WebDAV folder for Memona that signs Markdown with an ENS name, speaking -//! only the host's HTTP. +//! A WebDAV folder for Memona that signs Markdown with an ENS name and checks +//! the signature of every Markdown file it reads, speaking only the host's +//! HTTP. +mod badge; +mod check; mod dav; mod sdk; mod sign; +use check::Checker; + use sdk::host; use sdk::{ Capabilities, ChangePage, Entry, EntryKind, ErrorCode, FileInfo, Guest, @@ -23,13 +28,25 @@ const PROPS: &str = concat!( "", ); const MAX_CHUNK: u32 = 1024 * 1024; -/// The largest Markdown file this plugin will hold whole to sign. -const MAX_MARKDOWN: usize = 8 * 1024 * 1024; +/// The largest Markdown file a save signs. Memona gives each call into the +/// plugin 20 million WebAssembly instructions; signing costs about 6 million +/// plus 110 per byte, so a larger file would run out mid-call and leave the +/// save's outcome unknown. It is refused up front instead. +const MAX_SIGNED: usize = 96 * 1024; +/// Room a save may carry beyond [`MAX_SIGNED`] before its verdict line is +/// dropped, so a file read back at the limit can be saved unedited. +const BADGE_ALLOWANCE: usize = 4 * 1024; +/// The largest Markdown file a read decorates with a verdict line. Memona +/// opens files of at most one transfer, and a larger one streams through. +const MAX_DECORATED: usize = MAX_CHUNK as usize; +/// Where signers are checked when the connection names no RPC address. +const DEFAULT_RPC_URL: &str = "https://ethereum-sepolia-rpc.publicnode.com"; struct Session { root: String, authorization: Option, signer: Signer, + checker: Checker, } // The host creates one component instance per connection and calls into it @@ -155,6 +172,25 @@ fn describe( dav::multistatus(&body) } +fn is_http(url: &str) -> bool { + url.starts_with("https://") || url.starts_with("http://") +} + +/// Reads a whole response body, refusing one longer than `cap` bytes. +fn drain(call: &host::Exchange, cap: usize) -> Result, ErrorCode> { + let mut data = Vec::new(); + loop { + let chunk = call.pull(MAX_CHUNK).map_err(transport)?; + if chunk.is_empty() { + return Ok(data); + } + data.extend_from_slice(&chunk); + if data.len() > cap { + return Err(ErrorCode::Unsupported); + } + } +} + fn info_of(found: &dav::Found) -> FileInfo { FileInfo { kind: if found.collection { @@ -167,21 +203,38 @@ fn info_of(found: &dav::Found) -> FileInfo { } } -/// Reads one file through a GET that started at an offset. +/// Where a reader's bytes come from. +enum Source { + /// A GET that started at the reader's offset, streamed as it arrives. + Stream { + call: host::Exchange, + body: RefCell, + }, + /// A Markdown file already fetched whole and given its verdict line, and + /// how far into it the reader has handed bytes over. + Decorated { data: Vec, at: Cell }, +} + +/// Reads one file, streamed from the server or served from a decorated copy. struct DavReader { - call: host::Exchange, + source: Source, revision: Option, - body: RefCell, } impl GuestReader for DavReader { fn pull(&self, limit: u32) -> Result, ErrorCode> { - self - .body - .borrow_mut() - .pull(limit.min(MAX_CHUNK) as usize, |wanted| { - self.call.pull(wanted).map_err(transport) - }) + let limit = limit.min(MAX_CHUNK) as usize; + match &self.source { + Source::Stream { call, body } => body + .borrow_mut() + .pull(limit, |wanted| call.pull(wanted).map_err(transport)), + Source::Decorated { data, at } => { + let start = at.get().min(data.len()); + let end = start.saturating_add(limit).min(data.len()); + at.set(end); + Ok(data[start..end].to_vec()) + } + } } fn revision(&self) -> Option { self.revision.clone() @@ -220,7 +273,7 @@ impl GuestWriter for DavWriter { return self.send(&data); }; let mut markdown = markdown.borrow_mut(); - if markdown.len() + data.len() > MAX_MARKDOWN { + if markdown.len() + data.len() > MAX_SIGNED + BADGE_ALLOWANCE { return Err(ErrorCode::Unsupported); } markdown.extend_from_slice(&data); @@ -235,7 +288,16 @@ impl GuestWriter for DavWriter { } *self.done.borrow_mut() = true; if let Some(markdown) = &self.markdown { - let signed = session(|session| session.signer.sign(&markdown.borrow()))?; + // The verdict line a read added is Memona's copy of it coming back; it + // is dropped before signing so the server never stores a verdict. + let markdown = markdown.borrow(); + let text = + std::str::from_utf8(&markdown).map_err(|_| ErrorCode::InvalidInput)?; + let stored = badge::strip(text); + if stored.len() > MAX_SIGNED { + return Err(ErrorCode::Unsupported); + } + let signed = session(|session| session.signer.sign(stored.as_bytes()))?; for piece in signed.chunks(MAX_CHUNK as usize) { self.send(piece)?; } @@ -270,7 +332,9 @@ impl Guest for Webdav { .and_then(|(_, value)| text(Some(value.clone()))) }; let root = lookup("url").ok_or(ErrorCode::InvalidInput)?; - if !root.starts_with("https://") && !root.starts_with("http://") { + let rpc_url = + lookup("rpcUrl").unwrap_or_else(|| DEFAULT_RPC_URL.to_owned()); + if !is_http(&root) || !is_http(&rpc_url) { return Err(ErrorCode::InvalidInput); } let authorization = match (lookup("username"), lookup("password")) { @@ -287,6 +351,7 @@ impl Guest for Webdav { root, authorization, signer, + checker: Checker::new(rpc_url, lookup("trustedParent")), })); Ok(Capabilities { write: true, @@ -341,6 +406,21 @@ impl Guest for Webdav { /// A GET on a collection answers with whatever page the server likes to /// show, so the path is checked to be a file before any byte is read. + /// + /// A Markdown file of at most [`MAX_DECORATED`] bytes is fetched whole, + /// verified, and served with an `ens-verification` line in its frontmatter, + /// from `offset` into those decorated bytes. Memona reads a file in slices, + /// each through a fresh reader, so every slice decorates the same way; the + /// verdict cache keeps the line identical between slices. + /// + /// Size and revision always describe the file as stored, never the + /// decorated copy. The revision must stay the server's etag, because it is + /// what `If-Match` sends back on save, and one spelling across stat, list, + /// read and write is what lets Memona recognise its own save. The size + /// stays the stored length because learning the decorated one would take a + /// GET and a Sepolia lookup for every file a listing names; Memona uses the + /// size only to refuse oversized files up front and reads to end of file, + /// so a read running a line longer than the listed size is harmless. fn open_read(target: Locator, offset: u64) -> Result { let path = route(&target); let found = describe(&path, "0")?; @@ -348,20 +428,13 @@ impl Guest for Webdav { if entry.collection { return Err(ErrorCode::InvalidInput); } - let call = begin( - host::Method::Get, - &path, - vec![host::Header { - name: "range".to_owned(), - value: format!("bytes={offset}-"), - }], - )?; + let decorate = is_markdown(&path) && entry.length <= MAX_DECORATED as u64; + let range = (!decorate).then(|| host::Header { + name: "range".to_owned(), + value: format!("bytes={offset}-"), + }); + let call = begin(host::Method::Get, &path, range.into_iter().collect())?; let head = call.respond().map_err(transport)?; - let body = if dav::range_outcome(head.status)? { - dav::Body::default() - } else { - dav::Body::empty() - }; // The listing's etag is the one stat and list report, and the strong form // a later conditional write must send; the GET's may be a weak variant. let revision = entry.etag.clone().or_else(|| { @@ -371,11 +444,28 @@ impl Guest for Webdav { .find(|header| header.name.eq_ignore_ascii_case("etag")) .map(|header| dav::etag(&header.value)) }); - Ok(Reader::new(DavReader { - call, - revision, - body: RefCell::new(body), - })) + let source = if decorate { + dav::outcome(head.status)?; + let stored = drain(&call, MAX_DECORATED)?; + let shown = session(|session| { + Ok(session.checker.decorate(stored, &session.checker.rpc())) + })?; + Source::Decorated { + data: shown, + at: Cell::new(usize::try_from(offset).unwrap_or(usize::MAX)), + } + } else { + let body = if dav::range_outcome(head.status)? { + dav::Body::default() + } else { + dav::Body::empty() + }; + Source::Stream { + call, + body: RefCell::new(body), + } + }; + Ok(Reader::new(DavReader { source, revision })) } fn open_write(