diff --git a/Cargo.lock b/Cargo.lock index 0d25a14..23a3ec7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -488,7 +488,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memona-plugin-signature" -version = "0.1.0" +version = "0.1.1" dependencies = [ "alloy-primitives", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 2d013f2..7d0b107 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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." diff --git a/README.md b/README.md index 32cfad7..a9d239b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/manifest.json b/manifest.json index ac62456..b6facfc 100644 --- a/manifest.json +++ b/manifest.json @@ -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", diff --git a/src/lib.rs b/src/lib.rs index 3c42dd0..9e7163d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,7 +46,8 @@ const DEFAULT_RPC_URL: &str = "https://ethereum-sepolia-rpc.publicnode.com"; struct Session { root: String, authorization: Option, - signer: Signer, + /// None on a verify-only connection, which stores saves unsigned. + signer: Option, 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, diff --git a/src/sign.rs b/src/sign.rs index 4421a03..0023856 100644 --- a/src/sign.rs +++ b/src/sign.rs @@ -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, + private_key: Option, + ) -> Result, 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 { + 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(()) + } }