Markdown shows whether its ENS signature holds when read
Agents / agent (pull_request) Has been skipped
Agents / agent (pull_request) Has been skipped
This commit is contained in:
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+287
@@ -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<Verdict>) -> 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+364
@@ -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<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(())
|
||||
}
|
||||
}
|
||||
+118
-34
@@ -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,22 @@ const PROPS: &str = concat!(
|
||||
"</d:prop></d:propfind>",
|
||||
);
|
||||
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;
|
||||
/// 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<String>,
|
||||
signer: Signer,
|
||||
checker: Checker,
|
||||
}
|
||||
|
||||
// The host creates one component instance per connection and calls into it
|
||||
@@ -155,6 +169,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<Vec<u8>, 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 +200,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<dav::Body>,
|
||||
},
|
||||
/// 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<u8>, at: Cell<usize> },
|
||||
}
|
||||
|
||||
/// Reads one file, streamed from the server or served from a decorated copy.
|
||||
struct DavReader {
|
||||
call: host::Exchange,
|
||||
source: Source,
|
||||
revision: Option<String>,
|
||||
body: RefCell<dav::Body>,
|
||||
}
|
||||
|
||||
impl GuestReader for DavReader {
|
||||
fn pull(&self, limit: u32) -> Result<Vec<u8>, 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<String> {
|
||||
self.revision.clone()
|
||||
@@ -220,7 +270,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 {
|
||||
return Err(ErrorCode::Unsupported);
|
||||
}
|
||||
markdown.extend_from_slice(&data);
|
||||
@@ -235,7 +285,13 @@ 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);
|
||||
let signed = session(|session| session.signer.sign(stored.as_bytes()))?;
|
||||
for piece in signed.chunks(MAX_CHUNK as usize) {
|
||||
self.send(piece)?;
|
||||
}
|
||||
@@ -270,7 +326,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 +345,7 @@ impl Guest for Webdav {
|
||||
root,
|
||||
authorization,
|
||||
signer,
|
||||
checker: Checker::new(rpc_url, lookup("trustedParent")),
|
||||
}));
|
||||
Ok(Capabilities {
|
||||
write: true,
|
||||
@@ -341,6 +400,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<Reader, ErrorCode> {
|
||||
let path = route(&target);
|
||||
let found = describe(&path, "0")?;
|
||||
@@ -348,20 +422,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 +438,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(
|
||||
|
||||
Reference in New Issue
Block a user