A folder with no signer name or key verifies Markdown and saves it unsigned (#8)
Co-authored-by: Danny Kim <temeddix@gmail.com> Co-committed-by: Danny Kim <temeddix@gmail.com>
This commit was merged in pull request #8.
This commit is contained in:
Generated
+1
-1
@@ -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
@@ -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."
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user