Compare commits

..

7 Commits

Author SHA1 Message Date
Danny Kim 5e77f3ca67 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>
2026-09-26 02:27:50 +00:00
Danny Kim 6677e8ad72 Markdown files up to 96 KiB show whether their ENS signature holds (#7)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-25 21:26:08 +00:00
Danny Kim 967f257902 The README shows what each verification badge means (#6)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-25 20:17:21 +00:00
Danny Kim bf0e4561c0 Markdown shows whether its ENS signature holds when read (#5)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-25 20:11:58 +00:00
Danny Kim 1d36760782 Signed files keep their signature a string for every YAML parser (#4)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-25 19:43:09 +00:00
temeddix 5d46485dad A file's revision reads the same after a save as it does when listed (#3) 2026-09-25 17:43:50 +00:00
Danny Kim 5db8ed318a Sign Markdown saved to the folder with the configured ENS name and key (#2)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-25 17:02:52 +00:00
15 changed files with 1074 additions and 80 deletions
Generated
+115 -3
View File
@@ -19,6 +19,64 @@ dependencies = [
"sha3",
]
[[package]]
name = "alloy-sol-macro"
version = "1.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60dd79f578c3912f1fc2a150dbeb8110b8cfb976c60f98ebf6de9d5da5965a2b"
dependencies = [
"alloy-sol-macro-expander",
"alloy-sol-macro-input",
"proc-macro-error3",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "alloy-sol-macro-expander"
version = "1.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edb8520f2f94275e1caa73c85207dcc78ec402a9a4c429240f9a0783a8f16c0"
dependencies = [
"alloy-sol-macro-input",
"const-hex",
"heck",
"indexmap",
"proc-macro-error3",
"proc-macro2",
"quote",
"sha3",
"syn 2.0.119",
"syn-solidity",
]
[[package]]
name = "alloy-sol-macro-input"
version = "1.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66af2d9344882172993be5f5cbfd349fdfa52cb548f7af8715b446fb35ef6001"
dependencies = [
"const-hex",
"dunce",
"heck",
"macro-string 0.2.0",
"proc-macro2",
"quote",
"syn 2.0.119",
"syn-solidity",
]
[[package]]
name = "alloy-sol-types"
version = "1.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e6f87cf007caa54e95455875fbd25879b008d0d9c7c340a2c258b3a3400a28c"
dependencies = [
"alloy-primitives",
"alloy-sol-macro",
]
[[package]]
name = "anyhow"
version = "1.0.104"
@@ -204,6 +262,12 @@ dependencies = [
"crypto-common 0.2.2",
]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "ecdsa"
version = "0.16.9"
@@ -382,6 +446,17 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "macro-string"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "macro-string"
version = "0.3.0"
@@ -396,9 +471,10 @@ dependencies = [
[[package]]
name = "md-ens-signature"
version = "0.1.0"
source = "git+https://github.com/capsulizers/md-ens-signature?rev=051a5f611eeba8d61c66dbe5074a2e56517897a7#051a5f611eeba8d61c66dbe5074a2e56517897a7"
source = "git+https://github.com/capsulizers/md-ens-signature?rev=3a9f5e2bea1a668adc84342b82b98fee77909379#3a9f5e2bea1a668adc84342b82b98fee77909379"
dependencies = [
"alloy-primitives",
"alloy-sol-types",
"anyhow",
"k256",
"sha2",
@@ -412,8 +488,10 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memona-plugin-signature"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"alloy-primitives",
"anyhow",
"md-ens-signature",
"quick-xml",
"wit-bindgen",
@@ -454,6 +532,28 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "proc-macro-error-attr3"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82366fd7d8b7a440d66d13418820c69df9b3908bcb1a0476d7f5ce5d12f5a04d"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "proc-macro-error3"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b511283ea8a74b4b39447b128c5d00f03a356b7424554b13e298a5550100d9ac"
dependencies = [
"proc-macro-error-attr3",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -704,6 +804,18 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn-solidity"
version = "1.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb6a2e3c7f7a3e4e83d1752cec5d1e357ced0cf96e85419b6a07f227db3def3a"
dependencies = [
"paste",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "typenum"
version = "1.20.1"
@@ -824,7 +936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f19d63da8e478a370ef8192d90ccb517965b7747d6b46cd2d831dd0ef4d2541"
dependencies = [
"anyhow",
"macro-string",
"macro-string 0.3.0",
"prettyplease",
"proc-macro2",
"quote",
+6 -2
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."
@@ -12,7 +12,11 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
wit-bindgen = "=0.62.0"
quick-xml = { version = "0.37", default-features = false }
md-ens-signature = { git = "https://github.com/capsulizers/md-ens-signature", rev = "051a5f611eeba8d61c66dbe5074a2e56517897a7" }
anyhow = "1.0.104"
md-ens-signature = { git = "https://github.com/capsulizers/md-ens-signature", rev = "3a9f5e2bea1a668adc84342b82b98fee77909379" }
[dev-dependencies]
alloy-primitives = { version = "1.7.3", default-features = false }
[lints.clippy]
panic = "deny"
+61 -12
View File
@@ -12,14 +12,26 @@ It registers one provider, `signature`, for the `signed-webdav` scheme.
## Connecting
| Field | Meaning |
| --- | --- |
| Server address | The folder's address, for example `https://dav.example.com/files/` |
| Username | Optional account name for HTTP basic authentication |
| 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` |
![The connection form](https://git.capsulizers.com/commons/memona-plugin-signature/raw/branch/main/images/connection-form.png)
| Field | Key | Meaning |
| --- | --- | --- |
| 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` | 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.
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,
@@ -29,14 +41,51 @@ contents is refused rather than removed whole.
## Signing
Saving a `.md` file holds it whole, up to 8 MiB, and writes it once finished,
Saving a `.md` file holds it whole, up to 96 KiB, and writes it once finished,
with `signer` and `signature` set in its frontmatter; every other file streams
straight through. The signature is EIP-191 `personal_sign` over the signer name
and the body's SHA-256, made with the configured key; other frontmatter and the
body stay byte for byte. A key that is not 32 bytes of hex refuses the
connection, and a `.md` file that is not UTF-8 refuses the save. Reading passes
every file through unchanged. The RPC address is for verifying signers, which a
later version adds to reads.
connection, and a `.md` file that is not UTF-8 refuses the save.
![A note saved in Memona, signed and verified](https://git.capsulizers.com/commons/memona-plugin-signature/raw/branch/main/images/after-save.png)
## Verifying
Reading a `.md` file checks its signature against ENSv2 on Sepolia and adds an
`ens-verification` line to the frontmatter Memona receives, which Memona shows
as a badge. The owner of the signer name comes from the Universal Resolver's
`findOwner`, so a name its parent revoked stops counting at once.
| Badge | Meaning |
| --- | --- |
| `Verified: bob.team.eth` | The name's current owner signed this exact body |
| `Tampered: signed as bob.team.eth` | The body changed after signing, or another key signed it |
| `Unauthorized: bob.team.eth is not registered` | The signature holds, but no one owns the name now: it was revoked or never granted |
| `Unauthorized: bob.team.eth is not a member of team.eth` | The signer is outside the trusted parent name |
| `Unsigned` | The file has no `signer` and `signature` |
| `Not checked: Sepolia unreachable` | The RPC address did not answer, so nothing was decided |
| `Not checked: file too large` | The file is over 96 KiB, too large to hash within one call |
| Verified | Tampered |
| --- | --- |
| ![Verified](https://git.capsulizers.com/commons/memona-plugin-signature/raw/branch/main/images/verified.png) | ![Tampered](https://git.capsulizers.com/commons/memona-plugin-signature/raw/branch/main/images/tampered.png) |
| Unauthorized | Unsigned |
| --- | --- |
| ![Unauthorized](https://git.capsulizers.com/commons/memona-plugin-signature/raw/branch/main/images/unauthorized.png) | ![Unsigned](https://git.capsulizers.com/commons/memona-plugin-signature/raw/branch/main/images/unsigned.png) |
The line never reaches the server: a save drops it before signing, and one
stored on the server is replaced, so a file cannot claim its own verdict.
Verdicts are reused for 30 seconds per signed body, a failed lookup included,
so an editor rereading a file does not flood the RPC. Stat, list and saves
keep reporting the stored size and the server's etag, so `If-Match` works as
before.
Memona gives each call into a plugin a fixed instruction budget, which is where
the size limits come from: checking hashes the body and recovers a key, so it
covers files up to 96 KiB, the same as signing; a file up to 1 MiB is marked
not checked, and a larger one streams through without a badge.
## Building
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+12 -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 this name resolves to",
"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",
@@ -55,6 +55,13 @@
"kind": "text",
"required": false,
"default": "https://ethereum-sepolia-rpc.publicnode.com"
},
{
"key": "trustedParent",
"label": "Trusted parent name",
"description": "Only count signers that are this ENS name or one of its subnames, for example team.eth",
"kind": "text",
"required": false
}
]
}
+287
View File
@@ -0,0 +1,287 @@
//! The one frontmatter line that tells Memona what verification found.
//!
//! Memona shows every frontmatter key as a badge, so the verdict travels as a
//! line the plugin adds to the bytes a read returns and removes from the bytes
//! a write receives. It never reaches the server, and a line the server holds
//! is never trusted: it is removed before the plugin adds its own.
use md_ens_signature::verify::{Reason, Verdict};
use std::borrow::Cow;
/// The frontmatter key the verdict is shown under.
pub const KEY: &str = "ens-verification";
/// The badge for a file too large to hash within one host call.
pub const TOO_LARGE: &str = "Not checked: file too large";
/// The line that opens and closes a frontmatter block.
const FENCE: &str = "---";
/// The badge text for a verdict, or for a lookup that could not be made.
pub fn label(verdict: &anyhow::Result<Verdict>) -> String {
match verdict {
Ok(Verdict::Unsigned) => "Unsigned".to_owned(),
Ok(Verdict::Tampered { signer }) => format!("Tampered: signed as {signer}"),
Ok(Verdict::Unauthorized {
signer,
reason: Reason::Unregistered,
}) => format!("Unauthorized: {signer} is not registered"),
Ok(Verdict::Unauthorized {
signer,
reason: Reason::NotMember { parent },
}) => format!("Unauthorized: {signer} is not a member of {parent}"),
Ok(Verdict::Verified { signer, .. }) => format!("Verified: {signer}"),
Err(_) => "Not checked: Sepolia unreachable".to_owned(),
}
}
/// Returns a line without its trailing `\n` or `\r\n`.
fn without_newline(line: &str) -> &str {
let line = line.strip_suffix('\n').unwrap_or(line);
line.strip_suffix('\r').unwrap_or(line)
}
/// Where a closed frontmatter block sits: the byte after the opening fence
/// line, the byte where the closing fence line starts, and the byte after it.
fn block(markdown: &str) -> Option<(usize, usize, usize)> {
let mut lines = markdown.split_inclusive('\n');
let first = lines.next()?;
if !first.ends_with('\n') || without_newline(first) != FENCE {
return None;
}
let mut offset = first.len();
for line in lines {
if without_newline(line) == FENCE {
return Some((first.len(), offset, offset + line.len()));
}
offset += line.len();
}
None
}
/// Whether a frontmatter line sets the verdict key at the top level.
fn is_badge_line(line: &str) -> bool {
line
.strip_prefix(KEY)
.is_some_and(|rest| rest.trim_start_matches([' ', '\t']).starts_with(':'))
}
/// Whether a line continues the value above it, as a folded or block YAML
/// value does, rather than starting a key of its own.
fn is_continuation(line: &str) -> bool {
line.starts_with([' ', '\t']) && !without_newline(line).trim().is_empty()
}
/// Removes every verdict line, and whatever continues it, from the
/// frontmatter. A block left holding nothing is removed whole, since the
/// plugin made it; the body is never touched, so its digest stays the same.
pub fn strip(markdown: &str) -> Cow<'_, str> {
let Some((inner, close, end)) = block(markdown) else {
return Cow::Borrowed(markdown);
};
let mut kept = String::with_capacity(close - inner);
let mut removed = false;
let mut skipping = false;
for line in markdown[inner..close].split_inclusive('\n') {
if is_badge_line(line) {
removed = true;
skipping = true;
continue;
}
if skipping && is_continuation(line) {
continue;
}
skipping = false;
kept.push_str(line);
}
if !removed {
return Cow::Borrowed(markdown);
}
if kept.is_empty() {
return Cow::Borrowed(&markdown[end..]);
}
Cow::Owned(format!(
"{}{kept}{}",
&markdown[..inner],
&markdown[close..]
))
}
/// Quotes a badge for a YAML double-quoted scalar. A signer name comes from
/// the file, so its quotes, backslashes and control characters are escaped
/// or dropped rather than allowed to end the value early.
fn quoted(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 2);
out.push('"');
for character in text.chars() {
match character {
'"' | '\\' => {
out.push('\\');
out.push(character);
}
_ if character.is_control() => {}
_ => out.push(character),
}
}
out.push('"');
out
}
/// Returns the file with `text` as its only verdict line, first in the
/// frontmatter, so the badge leads the row. A file without frontmatter gets a
/// block holding just that line, which [`strip`] removes again.
///
/// Only the frontmatter is looked at and the rest is copied once, because a
/// host call has a fixed instruction budget that a large file must fit in.
pub fn inject(markdown: &str, text: &str) -> String {
let markdown = strip(markdown);
// The file's first line ending is the one the added lines follow.
let newline = match markdown.find('\n') {
Some(index) if markdown[..index].ends_with('\r') => "\r\n",
_ => "\n",
};
let line = format!("{KEY}: {}{newline}", quoted(text));
let (head, tail) = match block(&markdown) {
Some((inner, _, _)) => markdown.split_at(inner),
None => ("", markdown.as_ref()),
};
let mut out = String::with_capacity(markdown.len() + line.len() + 10);
if head.is_empty() {
out.push_str(FENCE);
out.push_str(newline);
out.push_str(&line);
out.push_str(FENCE);
out.push_str(newline);
} else {
out.push_str(head);
out.push_str(&line);
}
out.push_str(tail);
out
}
#[cfg(test)]
mod tests {
use super::{inject, label, strip};
use alloy_primitives::Address;
use anyhow::anyhow;
use md_ens_signature::document::body_digest;
use md_ens_signature::verify::{Reason, Verdict};
#[test]
fn every_verdict_reads_as_its_badge() {
let signer = "bob.team.eth".to_owned();
let cases = [
(Ok(Verdict::Unsigned), "Unsigned"),
(
Ok(Verdict::Tampered {
signer: signer.clone(),
}),
"Tampered: signed as bob.team.eth",
),
(
Ok(Verdict::Unauthorized {
signer: signer.clone(),
reason: Reason::Unregistered,
}),
"Unauthorized: bob.team.eth is not registered",
),
(
Ok(Verdict::Unauthorized {
signer: signer.clone(),
reason: Reason::NotMember {
parent: "team.eth".to_owned(),
},
}),
"Unauthorized: bob.team.eth is not a member of team.eth",
),
(
Ok(Verdict::Verified {
signer,
address: Address::ZERO,
}),
"Verified: bob.team.eth",
),
(Err(anyhow!("refused")), "Not checked: Sepolia unreachable"),
];
for (verdict, expected) in cases {
assert_eq!(label(&verdict), expected);
}
}
#[test]
fn the_badge_leads_existing_frontmatter() {
let file = "---\nsigner: a.eth\n---\n# Hi\n";
assert_eq!(
inject(file, "Verified: a.eth"),
"---\nens-verification: \"Verified: a.eth\"\nsigner: a.eth\n---\n# Hi\n"
);
}
#[test]
fn a_file_without_frontmatter_gets_a_block_that_strips_away() {
for file in ["# Hi\n", "", "---\nnot closed\n", "a\r\nb\r\n"] {
let shown = inject(file, "Unsigned");
assert!(shown.starts_with("---"), "{file:?}");
assert_eq!(strip(&shown), file);
}
assert_eq!(
inject("a\r\nb", "Unsigned"),
"---\r\nens-verification: \"Unsigned\"\r\n---\r\na\r\nb"
);
}
#[test]
fn stripping_restores_the_stored_bytes() {
for file in [
"---\nsigner: a.eth\nsignature: \"0x12\"\n---\nBody\n",
"---\r\ntitle: T\r\n---\r\nBody\r\n",
"---\n---\nBody",
"# No frontmatter\n",
] {
let shown = inject(file, "Verified: a.eth");
assert_ne!(shown, file);
let restored = strip(&shown);
// An empty block the badge filled is dropped, and the body with it
// keeps its digest.
assert_eq!(body_digest(&restored), body_digest(file));
if file != "---\n---\nBody" {
assert_eq!(restored, file);
}
}
}
#[test]
fn a_stored_verdict_is_never_trusted() {
let forged = "---\nens-verification: \"Verified: ceo.eth\"\n---\nPay me\n";
assert_eq!(
inject(forged, "Unsigned"),
"---\nens-verification: \"Unsigned\"\n---\nPay me\n"
);
}
#[test]
fn an_edited_or_reformatted_badge_is_still_stripped() {
let edited = concat!(
"---\n",
"title: T\n",
"ens-verification : 'Verified: x'\n",
"ens-verification: >-\n",
" folded\n",
" value\n",
"tags: [a]\n",
"---\n",
"ens-verification: body text stays\n",
);
assert_eq!(
strip(edited),
"---\ntitle: T\ntags: [a]\n---\nens-verification: body text stays\n"
);
}
#[test]
fn a_signer_cannot_break_out_of_the_quotes() {
assert_eq!(
inject("x", "Tampered: signed as a\"b\\c\u{7}.eth"),
"---\nens-verification: \"Tampered: signed as a\\\"b\\\\c.eth\"\n---\nx"
);
}
}
+372
View File
@@ -0,0 +1,372 @@
//! Verifying Markdown as it is read: the Sepolia lookup through the host's
//! HTTP, a verdict cache, and the badge added to what Memona is handed.
use crate::badge;
use crate::sdk::host;
use anyhow::{anyhow, bail};
use md_ens_signature::document::{body_digest, read_signature};
use md_ens_signature::ens::EthCall;
use md_ens_signature::verify::{EthCaller, Verdict, verify_fields};
use std::cell::RefCell;
use std::future::Future;
use std::pin::pin;
use std::task::{Context, Poll, Waker};
use std::time::{Duration, Instant};
/// How long a verdict is reused, so an editor that rereads a file every few
/// seconds asks Sepolia at most twice a minute about it.
const VERDICT_TTL: Duration = Duration::from_secs(30);
/// The largest JSON-RPC answer read. A `findOwner` answer is one word.
const MAX_RPC_ANSWER: usize = 64 * 1024;
/// The most bytes asked of the host per pull.
const RPC_CHUNK: u32 = 64 * 1024;
/// The largest file verified, the same as the largest one a save signs. A
/// first check costs about 4.9 million WebAssembly instructions plus 86 a
/// byte, or 98 with CRLF line endings, so a file this size leaves roughly 30
/// percent of Memona's 20 million per call to spare. Past this size the
/// call could run out and the file would not open at all.
const MAX_CHECKED: usize = 96 * 1024;
/// What a cached verdict belongs to: the signed body and the claim on it.
/// The trusted parent is fixed per connection, so it needs no place here.
type Claim = ([u8; 32], String, String);
/// One connection's view of Sepolia: where to ask, whom to trust, and what it
/// answered lately.
pub struct Checker {
rpc_url: String,
trusted_parent: Option<String>,
verdicts: RefCell<Vec<(Claim, Instant, String)>>,
}
/// Runs a future that never waits. Every [`EthCaller`] here makes a blocking
/// host call inside `call`, so the future finishes on its first poll; one
/// that did not would be a bug, reported as an error rather than spun on.
fn run<T>(future: impl Future<Output = T>) -> anyhow::Result<T> {
let mut context = Context::from_waker(Waker::noop());
match pin!(future).poll(&mut context) {
Poll::Ready(value) => Ok(value),
Poll::Pending => Err(anyhow!("The verification waited")),
}
}
impl Checker {
pub fn new(rpc_url: String, trusted_parent: Option<String>) -> Self {
Self {
rpc_url,
trusted_parent,
verdicts: RefCell::new(Vec::new()),
}
}
/// The badge text for a file, from the cache when the same body was signed
/// the same way less than [`VERDICT_TTL`] ago. A failed lookup is cached
/// too, so a node that is down is not asked again on every read.
pub fn badge(&self, markdown: &str, caller: &impl EthCaller) -> String {
let Some(fields) = read_signature(markdown) else {
return badge::label(&Ok(Verdict::Unsigned));
};
let claim = (
body_digest(markdown),
fields.signer.clone(),
fields.signature.clone(),
);
let now = Instant::now();
let mut verdicts = self.verdicts.borrow_mut();
verdicts.retain(|(_, at, _)| now.duration_since(*at) < VERDICT_TTL);
if let Some((_, _, text)) = verdicts.iter().find(|(key, ..)| *key == claim)
{
return text.clone();
}
// The digest taken for the cache is the one verified, so the body, most
// of the work for a large file, is hashed once.
let parent = self.trusted_parent.as_deref();
let verdict = run(verify_fields(&fields, &claim.0, parent, caller));
let text = badge::label(&verdict.flatten());
verdicts.push((claim, now, text.clone()));
text
}
/// The file as Memona should see it: the stored bytes with the verdict
/// line added. Bytes that are not UTF-8 are not Markdown to verify, and
/// pass through unchanged.
pub fn decorate(&self, stored: Vec<u8>, caller: &impl EthCaller) -> Vec<u8> {
let text = match String::from_utf8(stored) {
Ok(text) => text,
Err(error) => return error.into_bytes(),
};
let label = if text.len() > MAX_CHECKED {
badge::TOO_LARGE.to_owned()
} else {
self.badge(&text, caller)
};
badge::inject(&text, &label).into_bytes()
}
/// The caller that sends `eth_call` to this connection's RPC address.
pub fn rpc(&self) -> Rpc<'_> {
Rpc { url: &self.rpc_url }
}
}
/// Lowercase hex with a `0x` prefix, as JSON-RPC spells bytes.
fn hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(2 + bytes.len() * 2);
out.push_str("0x");
for byte in bytes {
out.push_str(&format!("{byte:02x}"));
}
out
}
/// The JSON-RPC request body for one `eth_call` against the latest block.
pub fn request_body(call: &EthCall) -> String {
format!(
concat!(
r#"{{"jsonrpc":"2.0","id":1,"method":"eth_call","#,
r#""params":[{{"to":"{}","data":"{}"}},"latest"]}}"#,
),
hex(call.to.as_slice()),
hex(&call.data),
)
}
/// Reads the bytes of a JSON-RPC answer's `result`. An answer carrying
/// `error`, or no hex `result`, is an error. The answer is one flat object
/// from a node, so finding the key and its string is enough.
pub fn result_bytes(answer: &str) -> anyhow::Result<Vec<u8>> {
let Some(start) = answer.find(r#""result""#) else {
bail!("The node answered without a result");
};
let rest = answer[start + 8..].trim_start();
let rest = rest.strip_prefix(':').unwrap_or(rest).trim_start();
let digits = rest
.strip_prefix("\"0x")
.and_then(|rest| rest.split('"').next())
.ok_or_else(|| anyhow!("The node's result is not hex"))?;
if digits.len() % 2 != 0 {
bail!("The node's result has an odd length");
}
(0..digits.len())
.step_by(2)
.map(|index| {
u8::from_str_radix(&digits[index..index + 2], 16)
.map_err(|_| anyhow!("The node's result is not hex"))
})
.collect()
}
/// Sends `eth_call` through the host's `http`, which blocks until the node
/// answers, so [`EthCaller::call`] never waits.
pub struct Rpc<'a> {
url: &'a str,
}
impl EthCaller for Rpc<'_> {
async fn call(&self, call: &EthCall) -> anyhow::Result<Vec<u8>> {
let failed = |_| anyhow!("The Sepolia node could not be reached");
let exchange = host::http(&host::Request {
method: host::Method::Post,
url: self.url.to_owned(),
headers: vec![host::Header {
name: "content-type".to_owned(),
value: "application/json".to_owned(),
}],
})
.map_err(failed)?;
exchange
.push(request_body(call).as_bytes())
.map_err(failed)?;
let head = exchange.respond().map_err(failed)?;
if !(200..300).contains(&head.status) {
bail!("The Sepolia node answered {}", head.status);
}
let mut answer = Vec::new();
loop {
let chunk = exchange.pull(RPC_CHUNK).map_err(failed)?;
if chunk.is_empty() {
break;
}
answer.extend_from_slice(&chunk);
if answer.len() > MAX_RPC_ANSWER {
bail!("The Sepolia node's answer is too long");
}
}
result_bytes(&String::from_utf8_lossy(&answer))
}
}
#[cfg(test)]
mod tests {
use super::{Checker, request_body, result_bytes};
use alloy_primitives::Address;
use anyhow::bail;
use md_ens_signature::document::{
SignatureFields, body_digest, write_signature,
};
use md_ens_signature::ens::{EthCall, find_owner_call};
use md_ens_signature::signature::{address_of, sign};
use md_ens_signature::verify::EthCaller;
use std::cell::Cell;
const KEY: [u8; 32] = [7; 32];
const BODY: &str = "---\ntitle: Plan\n---\nShip it.\n";
/// A Sepolia that knows one owner per name, or none, and counts questions.
struct FakeNode {
owners: Vec<(&'static str, Address)>,
down: bool,
asked: Cell<usize>,
}
impl FakeNode {
fn new(owners: Vec<(&'static str, Address)>) -> Self {
Self {
owners,
down: false,
asked: Cell::new(0),
}
}
}
impl EthCaller for FakeNode {
async fn call(&self, call: &EthCall) -> anyhow::Result<Vec<u8>> {
self.asked.set(self.asked.get() + 1);
if self.down {
bail!("connection refused");
}
let mut word = vec![0; 32];
for (name, owner) in &self.owners {
if find_owner_call(name)?.data == call.data {
word[12..].copy_from_slice(owner.as_slice());
}
}
Ok(word)
}
}
fn signed(signer: &str, body: &str) -> anyhow::Result<String> {
let fields = SignatureFields {
signer: signer.to_owned(),
signature: sign(&KEY, signer, &body_digest(body))?,
};
Ok(write_signature(body, &fields))
}
fn shown(checker: &Checker, file: &str, node: &FakeNode) -> String {
let bytes = checker.decorate(file.as_bytes().to_vec(), node);
String::from_utf8_lossy(&bytes).into_owned()
}
#[test]
fn each_verdict_lands_in_the_frontmatter() -> anyhow::Result<()> {
let owner = address_of(&KEY)?;
let node =
FakeNode::new(vec![("bob.team.eth", owner), ("eve.x.eth", owner)]);
let checker = Checker::new(String::new(), Some("team.eth".to_owned()));
let signed_file = signed("bob.team.eth", BODY)?;
let cases = [
(BODY.to_owned(), "Unsigned"),
(signed_file.clone(), "Verified: bob.team.eth"),
(
signed_file.replace("Ship it.", "Ship it later."),
"Tampered: signed as bob.team.eth",
),
(
signed("carol.team.eth", BODY)?,
"Unauthorized: carol.team.eth is not registered",
),
(
signed("eve.x.eth", BODY)?,
"Unauthorized: eve.x.eth is not a member of team.eth",
),
];
for (file, badge) in cases {
let expected = format!("---\nens-verification: \"{badge}\"\n");
assert!(
shown(&checker, &file, &node).starts_with(&expected),
"{badge}"
);
}
Ok(())
}
#[test]
fn a_node_that_is_down_is_named_not_blamed() -> anyhow::Result<()> {
let mut node = FakeNode::new(Vec::new());
node.down = true;
let checker = Checker::new(String::new(), None);
let file = signed("bob.team.eth", BODY)?;
let expected =
"---\nens-verification: \"Not checked: Sepolia unreachable\"";
assert!(shown(&checker, &file, &node).starts_with(expected));
// The failure is remembered for a while rather than retried per read.
shown(&checker, &file, &node);
assert_eq!(node.asked.get(), 1);
Ok(())
}
#[test]
fn a_verdict_is_reused_only_for_the_same_signed_body() -> anyhow::Result<()> {
let node = FakeNode::new(vec![("bob.team.eth", address_of(&KEY)?)]);
let checker = Checker::new(String::new(), None);
let file = signed("bob.team.eth", BODY)?;
for _ in 0..3 {
shown(&checker, &file, &node);
}
assert_eq!(node.asked.get(), 1);
// Other frontmatter is not signed, so it shares the verdict.
shown(
&checker,
&file.replace("title: Plan", "title: Other"),
&node,
);
assert_eq!(node.asked.get(), 1);
shown(&checker, &signed("bob.team.eth", "New body")?, &node);
assert_eq!(node.asked.get(), 2);
Ok(())
}
#[test]
fn a_file_too_large_to_hash_is_not_checked() -> anyhow::Result<()> {
let node = FakeNode::new(Vec::new());
let checker = Checker::new(String::new(), None);
let body = "x".repeat(super::MAX_CHECKED);
let file = signed("bob.team.eth", &body)?;
let expected = "---\nens-verification: \"Not checked: file too large\"";
assert!(shown(&checker, &file, &node).starts_with(expected));
assert_eq!(node.asked.get(), 0);
Ok(())
}
#[test]
fn bytes_that_are_not_text_pass_through() {
let node = FakeNode::new(Vec::new());
let checker = Checker::new(String::new(), None);
assert_eq!(checker.decorate(vec![0xff, 0x00], &node), vec![0xff, 0x00]);
assert_eq!(node.asked.get(), 0);
}
#[test]
fn the_rpc_request_and_answer_are_json_rpc() -> anyhow::Result<()> {
let call = find_owner_call("mdsig91205.eth")?;
let body = request_body(&call);
assert!(body.starts_with(concat!(
r#"{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"#,
r#""0x85edf8b6b7d4211e2b07aa687506b746357b92cf","data":"0x97ad3b3b"#,
)));
assert!(body.ends_with(r#""},"latest"]}"#));
let answer = r#"{"jsonrpc":"2.0","id":1,"result": "0x00ff10"}"#;
assert_eq!(result_bytes(answer)?, vec![0x00, 0xff, 0x10]);
assert_eq!(result_bytes(r#"{"result":"0x"}"#)?, Vec::<u8>::new());
for bad in [
r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32000}}"#,
r#"{"result":"0x0"}"#,
r#"{"result":"0xzz"}"#,
r#"{"result":null}"#,
] {
assert!(result_bytes(bad).is_err(), "{bad}");
}
Ok(())
}
}
+27 -3
View File
@@ -185,7 +185,7 @@ pub fn multistatus(body: &[u8]) -> Result<Vec<Found>, ErrorCode> {
b"getcontentlength" => {
entry.length = value.trim().parse().unwrap_or(0);
}
b"getetag" => entry.etag = Some(value.trim().to_owned()),
b"getetag" => entry.etag = Some(etag(&value)),
_ => {}
}
}
@@ -207,6 +207,22 @@ pub fn multistatus(body: &[u8]) -> Result<Vec<Found>, ErrorCode> {
Ok(found)
}
/// An entity tag in the quoted form an `ETag` header carries.
///
/// Some servers, WsgiDAV among them, list `getetag` bare while their `ETag`
/// header quotes the same tag, so a revision read from a listing and one
/// returned by a write would differ for the same bytes. Quoting a bare tag
/// gives every revision one spelling, and `If-Match` accepts that form. A tag
/// that is already quoted, weak or strong, is kept as it is.
pub fn etag(value: &str) -> String {
let value = value.trim();
if value.starts_with('"') || value.starts_with("W/\"") {
value.to_owned()
} else {
format!("\"{value}\"")
}
}
/// Strips any namespace prefix, so `d:href` and `D:href` read the same.
fn local(name: &[u8]) -> &[u8] {
match name.iter().position(|byte| *byte == b':') {
@@ -264,8 +280,8 @@ pub fn removable(found: &[Found]) -> Result<(), ErrorCode> {
#[cfg(test)]
mod tests {
use super::{
Body, Found, basic, children, multistatus, name_of, outcome, range_outcome,
removable, url,
Body, Found, basic, children, etag, multistatus, name_of, outcome,
range_outcome, removable, url,
};
use crate::sdk::ErrorCode;
@@ -414,4 +430,12 @@ mod tests {
assert_eq!(name_of(&found[1].href), "a b.md");
Ok(())
}
#[test]
fn bare_etags_are_quoted_like_headers() {
assert_eq!(etag("1996000-1790356720-264"), "\"1996000-1790356720-264\"");
assert_eq!(etag(" \"v1\" "), "\"v1\"");
assert_eq!(etag("W/\"v1\""), "W/\"v1\"");
assert_eq!(etag("W/v1"), "\"W/v1\"");
}
}
+133 -42
View File
@@ -1,10 +1,15 @@
//! A WebDAV folder for Memona that signs Markdown with an ENS name, speaking
//! only the host's HTTP.
//! A WebDAV folder for Memona that signs Markdown with an ENS name and checks
//! the signature of every Markdown file it reads, speaking only the host's
//! HTTP.
mod badge;
mod check;
mod dav;
mod sdk;
mod sign;
use check::Checker;
use sdk::host;
use sdk::{
Capabilities, ChangePage, Entry, EntryKind, ErrorCode, FileInfo, Guest,
@@ -23,13 +28,27 @@ const PROPS: &str = concat!(
"</d:prop></d:propfind>",
);
const MAX_CHUNK: u32 = 1024 * 1024;
/// The largest Markdown file this plugin will hold whole to sign.
const MAX_MARKDOWN: usize = 8 * 1024 * 1024;
/// The largest Markdown file a save signs. Memona gives each call into the
/// plugin 20 million WebAssembly instructions; signing costs about 5.6
/// million plus 85 per byte, or 97 with CRLF line endings, so a larger file
/// could run out mid-call and leave the save's outcome unknown. It is
/// refused up front instead.
const MAX_SIGNED: usize = 96 * 1024;
/// Room a save may carry beyond [`MAX_SIGNED`] before its verdict line is
/// dropped, so a file read back at the limit can be saved unedited.
const BADGE_ALLOWANCE: usize = 4 * 1024;
/// The largest Markdown file a read decorates with a verdict line. Memona
/// opens files of at most one transfer, and a larger one streams through.
const MAX_DECORATED: usize = MAX_CHUNK as usize;
/// Where signers are checked when the connection names no RPC address.
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,
}
// The host creates one component instance per connection and calls into it
@@ -155,6 +174,25 @@ fn describe(
dav::multistatus(&body)
}
fn is_http(url: &str) -> bool {
url.starts_with("https://") || url.starts_with("http://")
}
/// Reads a whole response body, refusing one longer than `cap` bytes.
fn drain(call: &host::Exchange, cap: usize) -> Result<Vec<u8>, ErrorCode> {
let mut data = Vec::new();
loop {
let chunk = call.pull(MAX_CHUNK).map_err(transport)?;
if chunk.is_empty() {
return Ok(data);
}
data.extend_from_slice(&chunk);
if data.len() > cap {
return Err(ErrorCode::Unsupported);
}
}
}
fn info_of(found: &dav::Found) -> FileInfo {
FileInfo {
kind: if found.collection {
@@ -167,21 +205,38 @@ fn info_of(found: &dav::Found) -> FileInfo {
}
}
/// Reads one file through a GET that started at an offset.
/// Where a reader's bytes come from.
enum Source {
/// A GET that started at the reader's offset, streamed as it arrives.
Stream {
call: host::Exchange,
body: RefCell<dav::Body>,
},
/// A Markdown file already fetched whole and given its verdict line, and
/// how far into it the reader has handed bytes over.
Decorated { data: Vec<u8>, at: Cell<usize> },
}
/// Reads one file, streamed from the server or served from a decorated copy.
struct DavReader {
call: host::Exchange,
source: Source,
revision: Option<String>,
body: RefCell<dav::Body>,
}
impl GuestReader for DavReader {
fn pull(&self, limit: u32) -> Result<Vec<u8>, ErrorCode> {
self
.body
.borrow_mut()
.pull(limit.min(MAX_CHUNK) as usize, |wanted| {
self.call.pull(wanted).map_err(transport)
})
let limit = limit.min(MAX_CHUNK) as usize;
match &self.source {
Source::Stream { call, body } => body
.borrow_mut()
.pull(limit, |wanted| call.pull(wanted).map_err(transport)),
Source::Decorated { data, at } => {
let start = at.get().min(data.len());
let end = start.saturating_add(limit).min(data.len());
at.set(end);
Ok(data[start..end].to_vec())
}
}
}
fn revision(&self) -> Option<String> {
self.revision.clone()
@@ -220,7 +275,7 @@ impl GuestWriter for DavWriter {
return self.send(&data);
};
let mut markdown = markdown.borrow_mut();
if markdown.len() + data.len() > MAX_MARKDOWN {
if markdown.len() + data.len() > MAX_SIGNED + BADGE_ALLOWANCE {
return Err(ErrorCode::Unsupported);
}
markdown.extend_from_slice(&data);
@@ -235,8 +290,19 @@ impl GuestWriter for DavWriter {
}
*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) {
// The verdict line a read added is Memona's copy of it coming back; it
// 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)?;
let stored = badge::strip(text);
if stored.len() > MAX_SIGNED {
return Err(ErrorCode::Unsupported);
}
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)?;
}
}
@@ -246,7 +312,7 @@ impl GuestWriter for DavWriter {
.headers
.iter()
.find(|header| header.name.eq_ignore_ascii_case("etag"))
.map(|header| header.value.clone());
.map(|header| dav::etag(&header.value));
Ok(Some(FileInfo {
kind: EntryKind::File,
size: self.written.get(),
@@ -270,7 +336,9 @@ impl Guest for Webdav {
.and_then(|(_, value)| text(Some(value.clone())))
};
let root = lookup("url").ok_or(ErrorCode::InvalidInput)?;
if !root.starts_with("https://") && !root.starts_with("http://") {
let rpc_url =
lookup("rpcUrl").unwrap_or_else(|| DEFAULT_RPC_URL.to_owned());
if !is_http(&root) || !is_http(&rpc_url) {
return Err(ErrorCode::InvalidInput);
}
let authorization = match (lookup("username"), lookup("password")) {
@@ -279,14 +347,12 @@ 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,
signer,
checker: Checker::new(rpc_url, lookup("trustedParent")),
}));
Ok(Capabilities {
write: true,
@@ -341,6 +407,21 @@ impl Guest for Webdav {
/// A GET on a collection answers with whatever page the server likes to
/// show, so the path is checked to be a file before any byte is read.
///
/// A Markdown file of at most [`MAX_DECORATED`] bytes is fetched whole,
/// verified, and served with an `ens-verification` line in its frontmatter,
/// from `offset` into those decorated bytes. Memona reads a file in slices,
/// each through a fresh reader, so every slice decorates the same way; the
/// verdict cache keeps the line identical between slices.
///
/// Size and revision always describe the file as stored, never the
/// decorated copy. The revision must stay the server's etag, because it is
/// what `If-Match` sends back on save, and one spelling across stat, list,
/// read and write is what lets Memona recognise its own save. The size
/// stays the stored length because learning the decorated one would take a
/// GET and a Sepolia lookup for every file a listing names; Memona uses the
/// size only to refuse oversized files up front and reads to end of file,
/// so a read running a line longer than the listed size is harmless.
fn open_read(target: Locator, offset: u64) -> Result<Reader, ErrorCode> {
let path = route(&target);
let found = describe(&path, "0")?;
@@ -348,20 +429,13 @@ impl Guest for Webdav {
if entry.collection {
return Err(ErrorCode::InvalidInput);
}
let call = begin(
host::Method::Get,
&path,
vec![host::Header {
name: "range".to_owned(),
value: format!("bytes={offset}-"),
}],
)?;
let decorate = is_markdown(&path) && entry.length <= MAX_DECORATED as u64;
let range = (!decorate).then(|| host::Header {
name: "range".to_owned(),
value: format!("bytes={offset}-"),
});
let call = begin(host::Method::Get, &path, range.into_iter().collect())?;
let head = call.respond().map_err(transport)?;
let body = if dav::range_outcome(head.status)? {
dav::Body::default()
} else {
dav::Body::empty()
};
// The listing's etag is the one stat and list report, and the strong form
// a later conditional write must send; the GET's may be a weak variant.
let revision = entry.etag.clone().or_else(|| {
@@ -369,13 +443,30 @@ impl Guest for Webdav {
.headers
.iter()
.find(|header| header.name.eq_ignore_ascii_case("etag"))
.map(|header| header.value.clone())
.map(|header| dav::etag(&header.value))
});
Ok(Reader::new(DavReader {
call,
revision,
body: RefCell::new(body),
}))
let source = if decorate {
dav::outcome(head.status)?;
let stored = drain(&call, MAX_DECORATED)?;
let shown = session(|session| {
Ok(session.checker.decorate(stored, &session.checker.rpc()))
})?;
Source::Decorated {
data: shown,
at: Cell::new(usize::try_from(offset).unwrap_or(usize::MAX)),
}
} else {
let body = if dav::range_outcome(head.status)? {
dav::Body::default()
} else {
dav::Body::empty()
};
Source::Stream {
call,
body: RefCell::new(body),
}
};
Ok(Reader::new(DavReader { source, revision }))
}
fn open_write(
+61 -13
View File
@@ -44,25 +44,52 @@ 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. A file that
/// is not UTF-8 is not Markdown this plugin can sign.
pub fn sign(&self, markdown: &[u8]) -> Result<Vec<u8>, ErrorCode> {
let text =
std::str::from_utf8(markdown).map_err(|_| ErrorCode::InvalidInput)?;
let signature = sign(&self.key, &self.name, &body_digest(text))
/// 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
/// second time here.
pub fn sign(&self, markdown: &str) -> Result<String, ErrorCode> {
let signature = sign(&self.key, &self.name, &body_digest(markdown))
.map_err(|_| ErrorCode::Internal)?;
let fields = SignatureFields {
signer: self.name.clone(),
signature,
};
Ok(write_signature(text, &fields).into_bytes())
Ok(write_signature(markdown, &fields))
}
}
/// 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};
@@ -75,10 +102,11 @@ mod tests {
-> Result<(), ErrorCode> {
let signer = Signer::new(" Laptop.Alice.eth ", KEY)?;
let saved = "---\ntitle: Notes\n---\n# Notes\n\nHello\n";
let signed = String::from_utf8(signer.sign(saved.as_bytes())?)
.map_err(|_| ErrorCode::Internal)?;
let signed = signer.sign(saved)?;
assert!(signed.starts_with("---\ntitle: Notes\n"));
assert!(signed.ends_with("---\n# Notes\n\nHello\n"));
// YAML reads a bare 0x... as an integer, so the signature stays a string.
assert!(signed.contains("\nsignature: \"0x"));
let fields = read_signature(&signed).ok_or(ErrorCode::Internal)?;
assert_eq!(fields.signer, "laptop.alice.eth");
let key = parse_key(KEY).ok_or(ErrorCode::Internal)?;
@@ -99,9 +127,29 @@ mod tests {
}
#[test]
fn a_file_that_is_not_text_is_refused() -> Result<(), ErrorCode> {
let signer = Signer::new("alice.eth", KEY)?;
assert_eq!(signer.sign(&[0xff, 0xfe]), Err(ErrorCode::InvalidInput));
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(())
}
}