Markdown files up to 96 KiB show whether their ENS signature holds (#7)

Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
This commit was merged in pull request #7.
This commit is contained in:
Danny Kim
2026-09-25 21:26:08 +00:00
committed by temeddix
parent 967f257902
commit 6677e8ad72
6 changed files with 34 additions and 34 deletions
Generated
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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 }
+3 -3
View File
@@ -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
+16 -8
View File
@@ -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
}
+6 -5
View File
@@ -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)?;
}
}
+7 -16
View File
@@ -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<Vec<u8>, 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<String, ErrorCode> {
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(())
}
}