Sign Markdown saved to the folder with the configured ENS name and key (#2)

Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
This commit was merged in pull request #2.
This commit is contained in:
Danny Kim
2026-09-25 17:02:52 +00:00
committed by temeddix
parent a4d1fae1d3
commit 5db8ed318a
6 changed files with 766 additions and 12 deletions
+45 -2
View File
@@ -3,6 +3,7 @@
mod dav;
mod sdk;
mod sign;
use sdk::host;
use sdk::{
@@ -10,6 +11,7 @@ use sdk::{
GuestReader, GuestWriter, ListPage, Locator, Reader, Setting, WriteCondition,
Writer,
};
use sign::Signer;
use std::cell::{Cell, RefCell};
/// The PROPFIND body. Asking for named properties rather than allprop keeps
@@ -21,12 +23,19 @@ 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;
struct Session {
root: String,
authorization: Option<String>,
signer: Signer,
}
// The host creates one component instance per connection and calls into it
// on a single thread, so this slot holds exactly one connection's session and
// is not shared state between connections. A WASI component has no other place
// to keep state between exported calls.
thread_local! {
static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) };
}
@@ -179,11 +188,27 @@ impl GuestReader for DavReader {
}
}
/// Whether a path names a Markdown file, the only kind this plugin signs.
fn is_markdown(path: &str) -> bool {
path.to_ascii_lowercase().ends_with(".md")
}
/// Writes one file through a PUT whose body is pushed in pieces.
struct DavWriter {
call: host::Exchange,
done: RefCell<bool>,
written: Cell<u64>,
/// A Markdown file waits here whole until `finish`, because its signature
/// covers the entire body and lands in the frontmatter at the front.
markdown: Option<RefCell<Vec<u8>>>,
}
impl DavWriter {
fn send(&self, data: &[u8]) -> Result<(), ErrorCode> {
self.call.push(data).map_err(transport)?;
self.written.set(self.written.get() + data.len() as u64);
Ok(())
}
}
impl GuestWriter for DavWriter {
@@ -191,8 +216,14 @@ impl GuestWriter for DavWriter {
if *self.done.borrow() {
return Err(ErrorCode::InvalidInput);
}
self.call.push(&data).map_err(transport)?;
self.written.set(self.written.get() + data.len() as u64);
let Some(markdown) = &self.markdown else {
return self.send(&data);
};
let mut markdown = markdown.borrow_mut();
if markdown.len() + data.len() > MAX_MARKDOWN {
return Err(ErrorCode::Unsupported);
}
markdown.extend_from_slice(&data);
Ok(())
}
/// One call is enough: the request body is already complete, so the response
@@ -203,6 +234,12 @@ impl GuestWriter for DavWriter {
return Err(ErrorCode::InvalidInput);
}
*self.done.borrow_mut() = true;
if let Some(markdown) = &self.markdown {
let signed = session(|session| session.signer.sign(&markdown.borrow()))?;
for piece in signed.chunks(MAX_CHUNK as usize) {
self.send(piece)?;
}
}
let head = self.call.respond().map_err(transport)?;
dav::outcome(head.status)?;
let revision = head
@@ -242,9 +279,14 @@ impl Guest for Webdav {
}
_ => None,
};
let signer = Signer::new(
&lookup("ensName").ok_or(ErrorCode::InvalidInput)?,
&lookup("privateKey").ok_or(ErrorCode::InvalidInput)?,
)?;
SESSION.set(Some(Session {
root,
authorization,
signer,
}));
Ok(Capabilities {
write: true,
@@ -358,6 +400,7 @@ impl Guest for Webdav {
call,
done: RefCell::new(false),
written: Cell::new(0),
markdown: is_markdown(&path).then(|| RefCell::new(Vec::new())),
}))
}
+107
View File
@@ -0,0 +1,107 @@
//! Signing a saved Markdown file, the only place this plugin touches its
//! bytes. Everything the md-ens-signature library does for the plugin goes
//! through the interface here.
use crate::sdk::ErrorCode;
use md_ens_signature::document::{
SignatureFields, body_digest, write_signature,
};
use md_ens_signature::signature::{address_of, sign};
/// One connection's signing identity: an ENS name and the private key bound
/// to it.
pub struct Signer {
name: String,
key: [u8; 32],
}
/// Reads a 32-byte private key written as 64 hex characters, with or without
/// a `0x` prefix.
fn parse_key(text: &str) -> Option<[u8; 32]> {
let digits = text.trim();
let digits = digits.strip_prefix("0x").unwrap_or(digits).as_bytes();
if digits.len() != 64 {
return None;
}
let mut key = [0u8; 32];
for (byte, pair) in key.iter_mut().zip(digits.chunks(2)) {
let text = std::str::from_utf8(pair).ok()?;
*byte = u8::from_str_radix(text, 16).ok()?;
}
Some(key)
}
impl Signer {
/// Takes the configured name and key, failing with `InvalidInput` when the
/// key cannot sign, so a bad key refuses the connection rather than every
/// later save.
pub fn new(name: &str, private_key: &str) -> Result<Self, ErrorCode> {
let name = name.trim().to_lowercase();
let key = parse_key(private_key).ok_or(ErrorCode::InvalidInput)?;
if name.is_empty() || address_of(&key).is_err() {
return Err(ErrorCode::InvalidInput);
}
Ok(Self { name, key })
}
/// 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))
.map_err(|_| ErrorCode::Internal)?;
let fields = SignatureFields {
signer: self.name.clone(),
signature,
};
Ok(write_signature(text, &fields).into_bytes())
}
}
#[cfg(test)]
mod tests {
use super::{Signer, parse_key};
use crate::sdk::ErrorCode;
use md_ens_signature::document::{body_digest, read_signature};
use md_ens_signature::signature::{address_of, recover};
const KEY: &str =
"0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
#[test]
fn a_signed_file_recovers_to_the_key_and_keeps_its_body()
-> 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)?;
assert!(signed.starts_with("---\ntitle: Notes\n"));
assert!(signed.ends_with("---\n# Notes\n\nHello\n"));
let fields = read_signature(&signed).ok_or(ErrorCode::Internal)?;
assert_eq!(fields.signer, "laptop.alice.eth");
let key = parse_key(KEY).ok_or(ErrorCode::Internal)?;
let recovered =
recover(&fields.signature, &fields.signer, &body_digest(&signed))
.map_err(|_| ErrorCode::Internal)?;
assert_eq!(Some(recovered), address_of(&key).ok());
Ok(())
}
#[test]
fn a_key_that_cannot_sign_refuses_the_connection() {
assert!(Signer::new("alice.eth", "0x1234").is_err());
assert!(Signer::new("alice.eth", &"zz".repeat(32)).is_err());
assert!(Signer::new("alice.eth", &"00".repeat(32)).is_err());
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(())
}
}