diff --git a/README.md b/README.md index 480a077..19137cc 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ It registers one provider, `signature`, for the `signed-webdav` scheme. | Server address | The folder's address, for example `https://dav.example.com/files/` | | Username | Optional account name for HTTP basic authentication | | Password | Optional password, stored as a secret | +| Signer name | The ENS name that signs, for example `laptop.alice.eth` | +| Signing key | The private key of the address the name resolves to, stored as a secret | +| Sepolia RPC address | The endpoint used to check signers against ENS, by default `https://ethereum-sepolia-rpc.publicnode.com` | Listing, reading, writing, creating directories, renaming and deleting behave as in the WebDAV plugin: reads and writes stream in pieces of at most 1 MiB, @@ -24,6 +27,15 @@ conditional writes use `If-Match` and `If-None-Match`, change polling is not supported, a rename never replaces an existing target, and a folder with contents is refused rather than removed whole. +## Signing + +Saving a `.md` file holds it whole, up to 8 MiB, and writes it once finished, +with `signer` and `signature` set in its frontmatter; every other file streams +straight through. Reading passes every file through unchanged. The signing +itself lives behind one small interface that the md-ens-signature library +fills in; until then a saved file is written exactly as it arrives. The RPC +address is for verifying signers, which a later version adds to reads. + ## Building ```sh diff --git a/manifest.json b/manifest.json index 7ad1a43..690981f 100644 --- a/manifest.json +++ b/manifest.json @@ -33,6 +33,28 @@ "label": "Password", "kind": "password", "required": false + }, + { + "key": "ensName", + "label": "Signer name", + "description": "The ENS name that signs saved Markdown, for example laptop.alice.eth", + "kind": "text", + "required": true + }, + { + "key": "privateKey", + "label": "Signing key", + "description": "The private key of the address this name resolves to", + "kind": "password", + "required": true + }, + { + "key": "rpcUrl", + "label": "Sepolia RPC address", + "description": "The Ethereum Sepolia endpoint used to check signers against ENS", + "kind": "text", + "required": false, + "default": "https://ethereum-sepolia-rpc.publicnode.com" } ] } diff --git a/src/lib.rs b/src/lib.rs index a365775..8dc8806 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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,10 +23,13 @@ const PROPS: &str = concat!( "", ); 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, + signer: Signer, } thread_local! { @@ -179,11 +184,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, written: Cell, + /// 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>>, +} + +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 +212,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 +230,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 +275,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 +396,7 @@ impl Guest for Webdav { call, done: RefCell::new(false), written: Cell::new(0), + markdown: is_markdown(&path).then(|| RefCell::new(Vec::new())), })) } diff --git a/src/sign.rs b/src/sign.rs new file mode 100644 index 0000000..f4e506e --- /dev/null +++ b/src/sign.rs @@ -0,0 +1,42 @@ +//! 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, so adopting the library replaces this file and +//! nothing else. + +use crate::sdk::ErrorCode; + +/// One connection's signing identity: an ENS name and the private key bound +/// to it. +/// +/// Until the library lands, a signer accepts any identity and leaves every +/// file exactly as it was saved. +pub struct Signer; + +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 { + Ok(Self) + } + + /// Returns the whole file with its `signer` and `signature` frontmatter set + /// for this identity, other frontmatter and the body untouched. + pub fn sign(&self, markdown: &[u8]) -> Result, ErrorCode> { + Ok(markdown.to_vec()) + } +} + +#[cfg(test)] +mod tests { + use super::Signer; + use crate::sdk::ErrorCode; + + #[test] + fn a_file_keeps_its_body() -> Result<(), ErrorCode> { + let signer = Signer::new("laptop.alice.eth", "0x01")?; + let signed = signer.sign(b"# Notes\n\nHello\n")?; + assert!(signed.ends_with(b"# Notes\n\nHello\n")); + Ok(()) + } +}