A folder with no signer name or key verifies Markdown and saves it unsigned
Agents / agent (pull_request) Has been skipped

This commit is contained in:
Danny Kim
2026-09-26 11:18:26 +09:00
parent 6677e8ad72
commit 46f2d1fdd6
6 changed files with 79 additions and 18 deletions
Generated
+1 -1
View File
@@ -488,7 +488,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memona-plugin-signature"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"alloy-primitives",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "memona-plugin-signature"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
license = "MIT"
description = "A WebDAV folder for Memona that signs Markdown files with an ENS name, built as a WASI component."
+8 -2
View File
@@ -19,11 +19,17 @@ It registers one provider, `signature`, for the `signed-webdav` scheme.
| Server address | `url` | The folder's address, for example `https://dav.example.com/files/` |
| Username | `username` | Optional account name for HTTP basic authentication |
| Password | `password` | Optional password, stored as a secret |
| Signer name | `ensName` | The ENS name that signs, for example `laptop.alice.eth` |
| Signing key | `privateKey` | The private key of the address that owns the name, stored as a secret |
| Signer name | `ensName` | Optional. The ENS name that signs, for example `laptop.alice.eth` |
| Signing key | `privateKey` | Optional. The private key of the address that owns the name, stored as a secret |
| Sepolia RPC address | `rpcUrl` | The endpoint used to check signers against ENS, by default `https://ethereum-sepolia-rpc.publicnode.com` |
| Trusted parent name | `trustedParent` | Optional. Only signers that are this name or one of its subnames count, for example `team.eth` |
Give the signer name and signing key together, or leave both empty for a
verify-only folder: reads still show a verdict, and a Markdown save is stored
unsigned, byte for byte except the verdict line, so a signature the file
already carries stays and an edit then reads as Tampered. Giving only one of
the two refuses the connection.
The connection's allowed hosts must include the RPC address's host as well as
the server's, or every check reads as Sepolia unreachable.
+5 -5
View File
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"id": "memona/signature",
"name": "Signature",
"version": "0.1.0",
"version": "0.1.1",
"apiVersion": "0.1.0",
"description": "Open a WebDAV server as a folder, and sign the Markdown saved to it with an ENS name.",
"license": "MIT",
@@ -37,16 +37,16 @@
{
"key": "ensName",
"label": "Signer name",
"description": "The ENS name that signs saved Markdown, for example laptop.alice.eth",
"description": "The ENS name that signs saved Markdown, for example laptop.alice.eth. Leave it and the key empty to only verify",
"kind": "text",
"required": true
"required": false
},
{
"key": "privateKey",
"label": "Signing key",
"description": "The private key of the address that owns this name",
"description": "The private key of the address that owns this name. Leave it and the name empty to only verify",
"kind": "password",
"required": true
"required": false
},
{
"key": "rpcUrl",
+8 -8
View File
@@ -46,7 +46,8 @@ const DEFAULT_RPC_URL: &str = "https://ethereum-sepolia-rpc.publicnode.com";
struct Session {
root: String,
authorization: Option<String>,
signer: Signer,
/// None on a verify-only connection, which stores saves unsigned.
signer: Option<Signer>,
checker: Checker,
}
@@ -290,7 +291,8 @@ impl GuestWriter for DavWriter {
*self.done.borrow_mut() = true;
if let Some(markdown) = &self.markdown {
// The verdict line a read added is Memona's copy of it coming back; it
// is dropped before signing so the server never stores a verdict.
// is dropped before signing, or before storing unsigned on a
// verify-only connection, so the server never stores a verdict.
let markdown = markdown.borrow();
let text =
std::str::from_utf8(&markdown).map_err(|_| ErrorCode::InvalidInput)?;
@@ -298,8 +300,9 @@ impl GuestWriter for DavWriter {
if stored.len() > MAX_SIGNED {
return Err(ErrorCode::Unsupported);
}
let signed = session(|session| session.signer.sign(&stored))?;
for piece in signed.as_bytes().chunks(MAX_CHUNK as usize) {
let saved =
session(|session| sign::stored(session.signer.as_ref(), &stored))?;
for piece in saved.as_bytes().chunks(MAX_CHUNK as usize) {
self.send(piece)?;
}
}
@@ -344,10 +347,7 @@ impl Guest for Webdav {
}
_ => None,
};
let signer = Signer::new(
&lookup("ensName").ok_or(ErrorCode::InvalidInput)?,
&lookup("privateKey").ok_or(ErrorCode::InvalidInput)?,
)?;
let signer = Signer::configured(lookup("ensName"), lookup("privateKey"))?;
SESSION.set(Some(Session {
root,
authorization,
+56 -1
View File
@@ -44,6 +44,20 @@ impl Signer {
Ok(Self { name, key })
}
/// The connection's identity from its optional name and key. Both sign
/// saves; neither leaves the folder verify-only; one without the other is
/// a mistake that refuses the connection.
pub fn configured(
name: Option<String>,
private_key: Option<String>,
) -> Result<Option<Self>, ErrorCode> {
match (name, private_key) {
(Some(name), Some(key)) => Self::new(&name, &key).map(Some),
(None, None) => Ok(None),
(Some(_), None) | (None, Some(_)) => Err(ErrorCode::InvalidInput),
}
}
/// Returns the whole file with its `signer` and `signature` frontmatter set
/// 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
@@ -59,9 +73,23 @@ impl Signer {
}
}
/// The bytes a save stores, its verdict line already dropped: signed when the
/// connection has a signer, and otherwise exactly as given, so a signature
/// the file already carries stays and an edit then reads as Tampered.
pub fn stored(
signer: Option<&Signer>,
markdown: &str,
) -> Result<String, ErrorCode> {
match signer {
Some(signer) => signer.sign(markdown),
None => Ok(markdown.to_owned()),
}
}
#[cfg(test)]
mod tests {
use super::{Signer, parse_key};
use super::{Signer, parse_key, stored};
use crate::badge;
use crate::sdk::ErrorCode;
use md_ens_signature::document::{body_digest, read_signature};
use md_ens_signature::signature::{address_of, recover};
@@ -97,4 +125,31 @@ mod tests {
assert!(Signer::new(" ", KEY).is_err());
assert!(Signer::new("alice.eth", &KEY[2..]).is_ok());
}
#[test]
fn a_name_and_key_come_together_or_not_at_all() {
let given = |text: &str| Some(text.to_owned());
assert!(matches!(Signer::configured(None, None), Ok(None)));
assert!(matches!(
Signer::configured(given("alice.eth"), given(KEY)),
Ok(Some(_))
));
assert!(Signer::configured(given("alice.eth"), None).is_err());
assert!(Signer::configured(None, given(KEY)).is_err());
}
#[test]
fn a_verify_only_save_keeps_every_byte_but_the_verdict()
-> Result<(), ErrorCode> {
let file = concat!(
"---\r\ntitle: Notes\r\nsigner: bob.team.eth\r\n",
"signature: \"0xabc\"\r\n---\r\n# Notes, edited\r\n\r\n",
);
let shown = badge::inject(file, "Verified: bob.team.eth");
assert_ne!(shown, file);
assert_eq!(stored(None, &badge::strip(&shown))?, file);
let plain = "# No frontmatter\n";
assert_eq!(stored(None, &badge::strip(plain))?, plain);
Ok(())
}
}