Ask for a signer name and key, and hold saved Markdown whole to sign it
Agents / agent (pull_request) Successful in 3m27s

This commit is contained in:
Danny Kim
2026-09-26 01:35:58 +09:00
parent 7f4e4e9719
commit 7ba8946661
4 changed files with 117 additions and 2 deletions
+12
View File
@@ -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/` | | Server address | The folder's address, for example `https://dav.example.com/files/` |
| Username | Optional account name for HTTP basic authentication | | Username | Optional account name for HTTP basic authentication |
| Password | Optional password, stored as a secret | | 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 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, 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 supported, a rename never replaces an existing target, and a folder with
contents is refused rather than removed whole. 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 ## Building
```sh ```sh
+22
View File
@@ -33,6 +33,28 @@
"label": "Password", "label": "Password",
"kind": "password", "kind": "password",
"required": false "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"
} }
] ]
} }
+41 -2
View File
@@ -3,6 +3,7 @@
mod dav; mod dav;
mod sdk; mod sdk;
mod sign;
use sdk::host; use sdk::host;
use sdk::{ use sdk::{
@@ -10,6 +11,7 @@ use sdk::{
GuestReader, GuestWriter, ListPage, Locator, Reader, Setting, WriteCondition, GuestReader, GuestWriter, ListPage, Locator, Reader, Setting, WriteCondition,
Writer, Writer,
}; };
use sign::Signer;
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
/// The PROPFIND body. Asking for named properties rather than allprop keeps /// The PROPFIND body. Asking for named properties rather than allprop keeps
@@ -21,10 +23,13 @@ const PROPS: &str = concat!(
"</d:prop></d:propfind>", "</d:prop></d:propfind>",
); );
const MAX_CHUNK: u32 = 1024 * 1024; 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 { struct Session {
root: String, root: String,
authorization: Option<String>, authorization: Option<String>,
signer: Signer,
} }
thread_local! { 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. /// Writes one file through a PUT whose body is pushed in pieces.
struct DavWriter { struct DavWriter {
call: host::Exchange, call: host::Exchange,
done: RefCell<bool>, done: RefCell<bool>,
written: Cell<u64>, 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 { impl GuestWriter for DavWriter {
@@ -191,8 +212,14 @@ impl GuestWriter for DavWriter {
if *self.done.borrow() { if *self.done.borrow() {
return Err(ErrorCode::InvalidInput); return Err(ErrorCode::InvalidInput);
} }
self.call.push(&data).map_err(transport)?; let Some(markdown) = &self.markdown else {
self.written.set(self.written.get() + data.len() as u64); 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(()) Ok(())
} }
/// One call is enough: the request body is already complete, so the response /// 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); return Err(ErrorCode::InvalidInput);
} }
*self.done.borrow_mut() = true; *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)?; let head = self.call.respond().map_err(transport)?;
dav::outcome(head.status)?; dav::outcome(head.status)?;
let revision = head let revision = head
@@ -242,9 +275,14 @@ impl Guest for Webdav {
} }
_ => None, _ => None,
}; };
let signer = Signer::new(
&lookup("ensName").ok_or(ErrorCode::InvalidInput)?,
&lookup("privateKey").ok_or(ErrorCode::InvalidInput)?,
)?;
SESSION.set(Some(Session { SESSION.set(Some(Session {
root, root,
authorization, authorization,
signer,
})); }));
Ok(Capabilities { Ok(Capabilities {
write: true, write: true,
@@ -358,6 +396,7 @@ impl Guest for Webdav {
call, call,
done: RefCell::new(false), done: RefCell::new(false),
written: Cell::new(0), written: Cell::new(0),
markdown: is_markdown(&path).then(|| RefCell::new(Vec::new())),
})) }))
} }
+42
View File
@@ -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<Self, ErrorCode> {
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<Vec<u8>, 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(())
}
}