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>
This commit was merged in pull request #2.
This commit is contained in:
+45
-2
@@ -3,6 +3,7 @@
|
||||
|
||||
mod dav;
|
||||
mod sdk;
|
||||
mod sign;
|
||||
|
||||
use sdk::host;
|
||||
use sdk::{
|
||||
@@ -10,6 +11,7 @@ use sdk::{
|
||||
GuestReader, GuestWriter, ListPage, Locator, Reader, Setting, WriteCondition,
|
||||
Writer,
|
||||
};
|
||||
use sign::Signer;
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
/// The PROPFIND body. Asking for named properties rather than allprop keeps
|
||||
@@ -21,12 +23,19 @@ 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;
|
||||
|
||||
struct Session {
|
||||
root: String,
|
||||
authorization: Option<String>,
|
||||
signer: Signer,
|
||||
}
|
||||
|
||||
// The host creates one component instance per connection and calls into it
|
||||
// on a single thread, so this slot holds exactly one connection's session and
|
||||
// is not shared state between connections. A WASI component has no other place
|
||||
// to keep state between exported calls.
|
||||
thread_local! {
|
||||
static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) };
|
||||
}
|
||||
@@ -179,11 +188,27 @@ impl GuestReader for DavReader {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a path names a Markdown file, the only kind this plugin signs.
|
||||
fn is_markdown(path: &str) -> bool {
|
||||
path.to_ascii_lowercase().ends_with(".md")
|
||||
}
|
||||
|
||||
/// Writes one file through a PUT whose body is pushed in pieces.
|
||||
struct DavWriter {
|
||||
call: host::Exchange,
|
||||
done: RefCell<bool>,
|
||||
written: Cell<u64>,
|
||||
/// A Markdown file waits here whole until `finish`, because its signature
|
||||
/// covers the entire body and lands in the frontmatter at the front.
|
||||
markdown: Option<RefCell<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl DavWriter {
|
||||
fn send(&self, data: &[u8]) -> Result<(), ErrorCode> {
|
||||
self.call.push(data).map_err(transport)?;
|
||||
self.written.set(self.written.get() + data.len() as u64);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl GuestWriter for DavWriter {
|
||||
@@ -191,8 +216,14 @@ impl GuestWriter for DavWriter {
|
||||
if *self.done.borrow() {
|
||||
return Err(ErrorCode::InvalidInput);
|
||||
}
|
||||
self.call.push(&data).map_err(transport)?;
|
||||
self.written.set(self.written.get() + data.len() as u64);
|
||||
let Some(markdown) = &self.markdown else {
|
||||
return self.send(&data);
|
||||
};
|
||||
let mut markdown = markdown.borrow_mut();
|
||||
if markdown.len() + data.len() > MAX_MARKDOWN {
|
||||
return Err(ErrorCode::Unsupported);
|
||||
}
|
||||
markdown.extend_from_slice(&data);
|
||||
Ok(())
|
||||
}
|
||||
/// One call is enough: the request body is already complete, so the response
|
||||
@@ -203,6 +234,12 @@ impl GuestWriter for DavWriter {
|
||||
return Err(ErrorCode::InvalidInput);
|
||||
}
|
||||
*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) {
|
||||
self.send(piece)?;
|
||||
}
|
||||
}
|
||||
let head = self.call.respond().map_err(transport)?;
|
||||
dav::outcome(head.status)?;
|
||||
let revision = head
|
||||
@@ -242,9 +279,14 @@ impl Guest for Webdav {
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let signer = Signer::new(
|
||||
&lookup("ensName").ok_or(ErrorCode::InvalidInput)?,
|
||||
&lookup("privateKey").ok_or(ErrorCode::InvalidInput)?,
|
||||
)?;
|
||||
SESSION.set(Some(Session {
|
||||
root,
|
||||
authorization,
|
||||
signer,
|
||||
}));
|
||||
Ok(Capabilities {
|
||||
write: true,
|
||||
@@ -358,6 +400,7 @@ impl Guest for Webdav {
|
||||
call,
|
||||
done: RefCell::new(false),
|
||||
written: Cell::new(0),
|
||||
markdown: is_markdown(&path).then(|| RefCell::new(Vec::new())),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
//! Signing a saved Markdown file, the only place this plugin touches its
|
||||
//! bytes. Everything the md-ens-signature library does for the plugin goes
|
||||
//! through the interface here.
|
||||
|
||||
use crate::sdk::ErrorCode;
|
||||
use md_ens_signature::document::{
|
||||
SignatureFields, body_digest, write_signature,
|
||||
};
|
||||
use md_ens_signature::signature::{address_of, sign};
|
||||
|
||||
/// One connection's signing identity: an ENS name and the private key bound
|
||||
/// to it.
|
||||
pub struct Signer {
|
||||
name: String,
|
||||
key: [u8; 32],
|
||||
}
|
||||
|
||||
/// Reads a 32-byte private key written as 64 hex characters, with or without
|
||||
/// a `0x` prefix.
|
||||
fn parse_key(text: &str) -> Option<[u8; 32]> {
|
||||
let digits = text.trim();
|
||||
let digits = digits.strip_prefix("0x").unwrap_or(digits).as_bytes();
|
||||
if digits.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
let mut key = [0u8; 32];
|
||||
for (byte, pair) in key.iter_mut().zip(digits.chunks(2)) {
|
||||
let text = std::str::from_utf8(pair).ok()?;
|
||||
*byte = u8::from_str_radix(text, 16).ok()?;
|
||||
}
|
||||
Some(key)
|
||||
}
|
||||
|
||||
impl Signer {
|
||||
/// Takes the configured name and key, failing with `InvalidInput` when the
|
||||
/// key cannot sign, so a bad key refuses the connection rather than every
|
||||
/// later save.
|
||||
pub fn new(name: &str, private_key: &str) -> Result<Self, ErrorCode> {
|
||||
let name = name.trim().to_lowercase();
|
||||
let key = parse_key(private_key).ok_or(ErrorCode::InvalidInput)?;
|
||||
if name.is_empty() || address_of(&key).is_err() {
|
||||
return Err(ErrorCode::InvalidInput);
|
||||
}
|
||||
Ok(Self { name, key })
|
||||
}
|
||||
|
||||
/// 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))
|
||||
.map_err(|_| ErrorCode::Internal)?;
|
||||
let fields = SignatureFields {
|
||||
signer: self.name.clone(),
|
||||
signature,
|
||||
};
|
||||
Ok(write_signature(text, &fields).into_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Signer, parse_key};
|
||||
use crate::sdk::ErrorCode;
|
||||
use md_ens_signature::document::{body_digest, read_signature};
|
||||
use md_ens_signature::signature::{address_of, recover};
|
||||
|
||||
const KEY: &str =
|
||||
"0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
|
||||
|
||||
#[test]
|
||||
fn a_signed_file_recovers_to_the_key_and_keeps_its_body()
|
||||
-> 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)?;
|
||||
assert!(signed.starts_with("---\ntitle: Notes\n"));
|
||||
assert!(signed.ends_with("---\n# Notes\n\nHello\n"));
|
||||
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)?;
|
||||
let recovered =
|
||||
recover(&fields.signature, &fields.signer, &body_digest(&signed))
|
||||
.map_err(|_| ErrorCode::Internal)?;
|
||||
assert_eq!(Some(recovered), address_of(&key).ok());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_that_cannot_sign_refuses_the_connection() {
|
||||
assert!(Signer::new("alice.eth", "0x1234").is_err());
|
||||
assert!(Signer::new("alice.eth", &"zz".repeat(32)).is_err());
|
||||
assert!(Signer::new("alice.eth", &"00".repeat(32)).is_err());
|
||||
assert!(Signer::new(" ", KEY).is_err());
|
||||
assert!(Signer::new("alice.eth", &KEY[2..]).is_ok());
|
||||
}
|
||||
|
||||
#[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));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user