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]]
|
[[package]]
|
||||||
name = "memona-plugin-signature"
|
name = "memona-plugin-signature"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alloy-primitives",
|
"alloy-primitives",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "memona-plugin-signature"
|
name = "memona-plugin-signature"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
description = "A WebDAV folder for Memona that signs Markdown files with an ENS name, built as a WASI component."
|
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/` |
|
| Server address | `url` | The folder's address, for example `https://dav.example.com/files/` |
|
||||||
| Username | `username` | Optional account name for HTTP basic authentication |
|
| Username | `username` | Optional account name for HTTP basic authentication |
|
||||||
| Password | `password` | Optional password, stored as a secret |
|
| Password | `password` | Optional password, stored as a secret |
|
||||||
| Signer name | `ensName` | The ENS name that signs, for example `laptop.alice.eth` |
|
| Signer name | `ensName` | Optional. 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 |
|
| 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` |
|
| 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` |
|
| 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 connection's allowed hosts must include the RPC address's host as well as
|
||||||
the server's, or every check reads as Sepolia unreachable.
|
the server's, or every check reads as Sepolia unreachable.
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -2,7 +2,7 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "memona/signature",
|
"id": "memona/signature",
|
||||||
"name": "Signature",
|
"name": "Signature",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"apiVersion": "0.1.0",
|
"apiVersion": "0.1.0",
|
||||||
"description": "Open a WebDAV server as a folder, and sign the Markdown saved to it with an ENS name.",
|
"description": "Open a WebDAV server as a folder, and sign the Markdown saved to it with an ENS name.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -37,16 +37,16 @@
|
|||||||
{
|
{
|
||||||
"key": "ensName",
|
"key": "ensName",
|
||||||
"label": "Signer name",
|
"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",
|
"kind": "text",
|
||||||
"required": true
|
"required": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "privateKey",
|
"key": "privateKey",
|
||||||
"label": "Signing key",
|
"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",
|
"kind": "password",
|
||||||
"required": true
|
"required": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "rpcUrl",
|
"key": "rpcUrl",
|
||||||
|
|||||||
+8
-8
@@ -46,7 +46,8 @@ const DEFAULT_RPC_URL: &str = "https://ethereum-sepolia-rpc.publicnode.com";
|
|||||||
struct Session {
|
struct Session {
|
||||||
root: String,
|
root: String,
|
||||||
authorization: Option<String>,
|
authorization: Option<String>,
|
||||||
signer: Signer,
|
/// None on a verify-only connection, which stores saves unsigned.
|
||||||
|
signer: Option<Signer>,
|
||||||
checker: Checker,
|
checker: Checker,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +291,8 @@ impl GuestWriter for DavWriter {
|
|||||||
*self.done.borrow_mut() = true;
|
*self.done.borrow_mut() = true;
|
||||||
if let Some(markdown) = &self.markdown {
|
if let Some(markdown) = &self.markdown {
|
||||||
// The verdict line a read added is Memona's copy of it coming back; it
|
// 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 markdown = markdown.borrow();
|
||||||
let text =
|
let text =
|
||||||
std::str::from_utf8(&markdown).map_err(|_| ErrorCode::InvalidInput)?;
|
std::str::from_utf8(&markdown).map_err(|_| ErrorCode::InvalidInput)?;
|
||||||
@@ -298,8 +300,9 @@ impl GuestWriter for DavWriter {
|
|||||||
if stored.len() > MAX_SIGNED {
|
if stored.len() > MAX_SIGNED {
|
||||||
return Err(ErrorCode::Unsupported);
|
return Err(ErrorCode::Unsupported);
|
||||||
}
|
}
|
||||||
let signed = session(|session| session.signer.sign(&stored))?;
|
let saved =
|
||||||
for piece in signed.as_bytes().chunks(MAX_CHUNK as usize) {
|
session(|session| sign::stored(session.signer.as_ref(), &stored))?;
|
||||||
|
for piece in saved.as_bytes().chunks(MAX_CHUNK as usize) {
|
||||||
self.send(piece)?;
|
self.send(piece)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,10 +347,7 @@ impl Guest for Webdav {
|
|||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let signer = Signer::new(
|
let signer = Signer::configured(lookup("ensName"), lookup("privateKey"))?;
|
||||||
&lookup("ensName").ok_or(ErrorCode::InvalidInput)?,
|
|
||||||
&lookup("privateKey").ok_or(ErrorCode::InvalidInput)?,
|
|
||||||
)?;
|
|
||||||
SESSION.set(Some(Session {
|
SESSION.set(Some(Session {
|
||||||
root,
|
root,
|
||||||
authorization,
|
authorization,
|
||||||
|
|||||||
+56
-1
@@ -44,6 +44,20 @@ impl Signer {
|
|||||||
Ok(Self { name, key })
|
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
|
/// Returns the whole file with its `signer` and `signature` frontmatter set
|
||||||
/// for this identity, other frontmatter and the body untouched. The caller
|
/// 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
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{Signer, parse_key};
|
use super::{Signer, parse_key, stored};
|
||||||
|
use crate::badge;
|
||||||
use crate::sdk::ErrorCode;
|
use crate::sdk::ErrorCode;
|
||||||
use md_ens_signature::document::{body_digest, read_signature};
|
use md_ens_signature::document::{body_digest, read_signature};
|
||||||
use md_ens_signature::signature::{address_of, recover};
|
use md_ens_signature::signature::{address_of, recover};
|
||||||
@@ -97,4 +125,31 @@ mod tests {
|
|||||||
assert!(Signer::new(" ", KEY).is_err());
|
assert!(Signer::new(" ", KEY).is_err());
|
||||||
assert!(Signer::new("alice.eth", &KEY[2..]).is_ok());
|
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