From ef95c010ee8e50d1b4d03a93246e7d576684c1ec Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Sat, 26 Sep 2026 06:13:00 +0900 Subject: [PATCH] Markdown files up to 96 KiB show whether their ENS signature holds --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 6 +++--- src/check.rs | 24 ++++++++++++++++-------- src/lib.rs | 11 ++++++----- src/sign.rs | 23 +++++++---------------- 6 files changed, 34 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 573b910..0d25a14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -471,7 +471,7 @@ dependencies = [ [[package]] name = "md-ens-signature" version = "0.1.0" -source = "git+https://github.com/capsulizers/md-ens-signature?rev=86878b144154c894948c595d2f0d66fbbd36ae4b#86878b144154c894948c595d2f0d66fbbd36ae4b" +source = "git+https://github.com/capsulizers/md-ens-signature?rev=3a9f5e2bea1a668adc84342b82b98fee77909379#3a9f5e2bea1a668adc84342b82b98fee77909379" dependencies = [ "alloy-primitives", "alloy-sol-types", diff --git a/Cargo.toml b/Cargo.toml index 2cfb118..2d013f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ crate-type = ["cdylib", "rlib"] 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" } +md-ens-signature = { git = "https://github.com/capsulizers/md-ens-signature", rev = "3a9f5e2bea1a668adc84342b82b98fee77909379" } [dev-dependencies] alloy-primitives = { version = "1.7.3", default-features = false } diff --git a/README.md b/README.md index beeb6f6..32cfad7 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ as a badge. The owner of the signer name comes from the Universal Resolver's | `Unauthorized: bob.team.eth is not a member of team.eth` | The signer is outside the trusted parent name | | `Unsigned` | The file has no `signer` and `signature` | | `Not checked: Sepolia unreachable` | The RPC address did not answer, so nothing was decided | -| `Not checked: file too large` | The file is over 40 KiB, too large to hash within one call | +| `Not checked: file too large` | The file is over 96 KiB, too large to hash within one call | | Verified | Tampered | | --- | --- | @@ -78,8 +78,8 @@ before. Memona gives each call into a plugin a fixed instruction budget, which is where the size limits come from: checking hashes the body and recovers a key, so it -covers files up to 40 KiB; a file up to 1 MiB is marked not checked, and a -larger one streams through without a badge. +covers files up to 96 KiB, the same as signing; a file up to 1 MiB is marked +not checked, and a larger one streams through without a badge. ## Building diff --git a/src/check.rs b/src/check.rs index f82d9ff..37063fc 100644 --- a/src/check.rs +++ b/src/check.rs @@ -6,7 +6,7 @@ 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 md_ens_signature::verify::{EthCaller, Verdict, verify_fields}; use std::cell::RefCell; use std::future::Future; use std::pin::pin; @@ -20,11 +20,12 @@ const VERDICT_TTL: Duration = Duration::from_secs(30); 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; +/// The largest file verified, the same as the largest one a save signs. A +/// first check costs about 4.9 million WebAssembly instructions plus 86 a +/// byte, or 98 with CRLF line endings, so a file this size leaves roughly 30 +/// percent of Memona's 20 million per call to spare. Past this size the +/// call could run out and the file would not open at all. +const MAX_CHECKED: usize = 96 * 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. @@ -65,7 +66,11 @@ impl Checker { let Some(fields) = read_signature(markdown) else { return badge::label(&Ok(Verdict::Unsigned)); }; - let claim = (body_digest(markdown), fields.signer, fields.signature); + let claim = ( + body_digest(markdown), + fields.signer.clone(), + fields.signature.clone(), + ); let now = Instant::now(); let mut verdicts = self.verdicts.borrow_mut(); verdicts.retain(|(_, at, _)| now.duration_since(*at) < VERDICT_TTL); @@ -73,8 +78,11 @@ impl Checker { { return text.clone(); } + // The digest taken for the cache is the one verified, so the body, most + // of the work for a large file, is hashed once. let parent = self.trusted_parent.as_deref(); - let text = badge::label(&run(verify(markdown, parent, caller)).flatten()); + let verdict = run(verify_fields(&fields, &claim.0, parent, caller)); + let text = badge::label(&verdict.flatten()); verdicts.push((claim, now, text.clone())); text } diff --git a/src/lib.rs b/src/lib.rs index 051e887..3c42dd0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,9 +29,10 @@ const PROPS: &str = concat!( ); const MAX_CHUNK: u32 = 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. +/// plugin 20 million WebAssembly instructions; signing costs about 5.6 +/// million plus 85 per byte, or 97 with CRLF line endings, so a larger file +/// could 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. @@ -297,8 +298,8 @@ impl GuestWriter for DavWriter { 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) { + let signed = session(|session| session.signer.sign(&stored))?; + for piece in signed.as_bytes().chunks(MAX_CHUNK as usize) { self.send(piece)?; } } diff --git a/src/sign.rs b/src/sign.rs index 7e93542..4421a03 100644 --- a/src/sign.rs +++ b/src/sign.rs @@ -45,18 +45,17 @@ impl Signer { } /// Returns the whole file with its `signer` and `signature` frontmatter set - /// for this identity, other frontmatter and the body untouched. A file that - /// is not UTF-8 is not Markdown this plugin can sign. - pub fn sign(&self, markdown: &[u8]) -> Result, ErrorCode> { - let text = - std::str::from_utf8(markdown).map_err(|_| ErrorCode::InvalidInput)?; - let signature = sign(&self.key, &self.name, &body_digest(text)) + /// for this identity, other frontmatter and the body untouched. The caller + /// has already refused bytes that are not UTF-8, so they are not checked a + /// second time here. + pub fn sign(&self, markdown: &str) -> Result { + let signature = sign(&self.key, &self.name, &body_digest(markdown)) .map_err(|_| ErrorCode::Internal)?; let fields = SignatureFields { signer: self.name.clone(), signature, }; - Ok(write_signature(text, &fields).into_bytes()) + Ok(write_signature(markdown, &fields)) } } @@ -75,8 +74,7 @@ mod tests { -> Result<(), ErrorCode> { let signer = Signer::new(" Laptop.Alice.eth ", KEY)?; let saved = "---\ntitle: Notes\n---\n# Notes\n\nHello\n"; - let signed = String::from_utf8(signer.sign(saved.as_bytes())?) - .map_err(|_| ErrorCode::Internal)?; + let signed = signer.sign(saved)?; assert!(signed.starts_with("---\ntitle: Notes\n")); assert!(signed.ends_with("---\n# Notes\n\nHello\n")); // YAML reads a bare 0x... as an integer, so the signature stays a string. @@ -99,11 +97,4 @@ mod tests { assert!(Signer::new(" ", KEY).is_err()); assert!(Signer::new("alice.eth", &KEY[2..]).is_ok()); } - - #[test] - fn a_file_that_is_not_text_is_refused() -> Result<(), ErrorCode> { - let signer = Signer::new("alice.eth", KEY)?; - assert_eq!(signer.sign(&[0xff, 0xfe]), Err(ErrorCode::InvalidInput)); - Ok(()) - } } -- 2.52.0