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:
Generated
+1
-1
@@ -471,7 +471,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "md-ens-signature"
|
name = "md-ens-signature"
|
||||||
version = "0.1.0"
|
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 = [
|
dependencies = [
|
||||||
"alloy-primitives",
|
"alloy-primitives",
|
||||||
"alloy-sol-types",
|
"alloy-sol-types",
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ crate-type = ["cdylib", "rlib"]
|
|||||||
wit-bindgen = "=0.62.0"
|
wit-bindgen = "=0.62.0"
|
||||||
quick-xml = { version = "0.37", default-features = false }
|
quick-xml = { version = "0.37", default-features = false }
|
||||||
anyhow = "1.0.104"
|
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]
|
[dev-dependencies]
|
||||||
alloy-primitives = { version = "1.7.3", default-features = false }
|
alloy-primitives = { version = "1.7.3", default-features = false }
|
||||||
|
|||||||
@@ -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 |
|
| `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` |
|
| `Unsigned` | The file has no `signer` and `signature` |
|
||||||
| `Not checked: Sepolia unreachable` | The RPC address did not answer, so nothing was decided |
|
| `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 |
|
| Verified | Tampered |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -78,8 +78,8 @@ before.
|
|||||||
|
|
||||||
Memona gives each call into a plugin a fixed instruction budget, which is where
|
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
|
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
|
covers files up to 96 KiB, the same as signing; a file up to 1 MiB is marked
|
||||||
larger one streams through without a badge.
|
not checked, and a larger one streams through without a badge.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
|
|||||||
+16
-8
@@ -6,7 +6,7 @@ use crate::sdk::host;
|
|||||||
use anyhow::{anyhow, bail};
|
use anyhow::{anyhow, bail};
|
||||||
use md_ens_signature::document::{body_digest, read_signature};
|
use md_ens_signature::document::{body_digest, read_signature};
|
||||||
use md_ens_signature::ens::EthCall;
|
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::cell::RefCell;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::pin::pin;
|
use std::pin::pin;
|
||||||
@@ -20,11 +20,12 @@ const VERDICT_TTL: Duration = Duration::from_secs(30);
|
|||||||
const MAX_RPC_ANSWER: usize = 64 * 1024;
|
const MAX_RPC_ANSWER: usize = 64 * 1024;
|
||||||
/// The most bytes asked of the host per pull.
|
/// The most bytes asked of the host per pull.
|
||||||
const RPC_CHUNK: u32 = 64 * 1024;
|
const RPC_CHUNK: u32 = 64 * 1024;
|
||||||
/// The largest file verified. A first check hashes the body twice, at about
|
/// The largest file verified, the same as the largest one a save signs. A
|
||||||
/// 100 WebAssembly instructions a byte, and recovers a key for about 8.5
|
/// first check costs about 4.9 million WebAssembly instructions plus 86 a
|
||||||
/// million more, all inside Memona's 20 million per call; past this size the
|
/// byte, or 98 with CRLF line endings, so a file this size leaves roughly 30
|
||||||
/// call would run out and the file would not open at all.
|
/// percent of Memona's 20 million per call to spare. Past this size the
|
||||||
const MAX_CHECKED: usize = 40 * 1024;
|
/// 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.
|
/// 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.
|
/// 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 {
|
let Some(fields) = read_signature(markdown) else {
|
||||||
return badge::label(&Ok(Verdict::Unsigned));
|
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 now = Instant::now();
|
||||||
let mut verdicts = self.verdicts.borrow_mut();
|
let mut verdicts = self.verdicts.borrow_mut();
|
||||||
verdicts.retain(|(_, at, _)| now.duration_since(*at) < VERDICT_TTL);
|
verdicts.retain(|(_, at, _)| now.duration_since(*at) < VERDICT_TTL);
|
||||||
@@ -73,8 +78,11 @@ impl Checker {
|
|||||||
{
|
{
|
||||||
return text.clone();
|
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 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()));
|
verdicts.push((claim, now, text.clone()));
|
||||||
text
|
text
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-5
@@ -29,9 +29,10 @@ const PROPS: &str = concat!(
|
|||||||
);
|
);
|
||||||
const MAX_CHUNK: u32 = 1024 * 1024;
|
const MAX_CHUNK: u32 = 1024 * 1024;
|
||||||
/// The largest Markdown file a save signs. Memona gives each call into the
|
/// The largest Markdown file a save signs. Memona gives each call into the
|
||||||
/// plugin 20 million WebAssembly instructions; signing costs about 6 million
|
/// plugin 20 million WebAssembly instructions; signing costs about 5.6
|
||||||
/// plus 110 per byte, so a larger file would run out mid-call and leave the
|
/// million plus 85 per byte, or 97 with CRLF line endings, so a larger file
|
||||||
/// save's outcome unknown. It is refused up front instead.
|
/// could run out mid-call and leave the save's outcome unknown. It is
|
||||||
|
/// refused up front instead.
|
||||||
const MAX_SIGNED: usize = 96 * 1024;
|
const MAX_SIGNED: usize = 96 * 1024;
|
||||||
/// Room a save may carry beyond [`MAX_SIGNED`] before its verdict line is
|
/// 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.
|
/// 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 {
|
if stored.len() > MAX_SIGNED {
|
||||||
return Err(ErrorCode::Unsupported);
|
return Err(ErrorCode::Unsupported);
|
||||||
}
|
}
|
||||||
let signed = session(|session| session.signer.sign(stored.as_bytes()))?;
|
let signed = session(|session| session.signer.sign(&stored))?;
|
||||||
for piece in signed.chunks(MAX_CHUNK as usize) {
|
for piece in signed.as_bytes().chunks(MAX_CHUNK as usize) {
|
||||||
self.send(piece)?;
|
self.send(piece)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-16
@@ -45,18 +45,17 @@ impl Signer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the whole file with its `signer` and `signature` frontmatter set
|
/// Returns the whole file with its `signer` and `signature` frontmatter set
|
||||||
/// for this identity, other frontmatter and the body untouched. A file that
|
/// for this identity, other frontmatter and the body untouched. The caller
|
||||||
/// is not UTF-8 is not Markdown this plugin can sign.
|
/// has already refused bytes that are not UTF-8, so they are not checked a
|
||||||
pub fn sign(&self, markdown: &[u8]) -> Result<Vec<u8>, ErrorCode> {
|
/// second time here.
|
||||||
let text =
|
pub fn sign(&self, markdown: &str) -> Result<String, ErrorCode> {
|
||||||
std::str::from_utf8(markdown).map_err(|_| ErrorCode::InvalidInput)?;
|
let signature = sign(&self.key, &self.name, &body_digest(markdown))
|
||||||
let signature = sign(&self.key, &self.name, &body_digest(text))
|
|
||||||
.map_err(|_| ErrorCode::Internal)?;
|
.map_err(|_| ErrorCode::Internal)?;
|
||||||
let fields = SignatureFields {
|
let fields = SignatureFields {
|
||||||
signer: self.name.clone(),
|
signer: self.name.clone(),
|
||||||
signature,
|
signature,
|
||||||
};
|
};
|
||||||
Ok(write_signature(text, &fields).into_bytes())
|
Ok(write_signature(markdown, &fields))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,8 +74,7 @@ mod tests {
|
|||||||
-> Result<(), ErrorCode> {
|
-> Result<(), ErrorCode> {
|
||||||
let signer = Signer::new(" Laptop.Alice.eth ", KEY)?;
|
let signer = Signer::new(" Laptop.Alice.eth ", KEY)?;
|
||||||
let saved = "---\ntitle: Notes\n---\n# Notes\n\nHello\n";
|
let saved = "---\ntitle: Notes\n---\n# Notes\n\nHello\n";
|
||||||
let signed = String::from_utf8(signer.sign(saved.as_bytes())?)
|
let signed = signer.sign(saved)?;
|
||||||
.map_err(|_| ErrorCode::Internal)?;
|
|
||||||
assert!(signed.starts_with("---\ntitle: Notes\n"));
|
assert!(signed.starts_with("---\ntitle: Notes\n"));
|
||||||
assert!(signed.ends_with("---\n# Notes\n\nHello\n"));
|
assert!(signed.ends_with("---\n# Notes\n\nHello\n"));
|
||||||
// YAML reads a bare 0x... as an integer, so the signature stays a string.
|
// 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(" ", KEY).is_err());
|
||||||
assert!(Signer::new("alice.eth", &KEY[2..]).is_ok());
|
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(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user