use crate::file_root;
use base64::Engine;
use ed25519_dalek::pkcs8::{spki::DecodePublicKey, DecodePrivateKey};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use ikigai_core::{
ActionSpec, ArgRef, ArgSpec, Description, Endpoint, Error, Invocation, Iri, ReprType,
Representation, Request, Result, Verb,
};
pub const CAP_DECIDE_MINT: &str = "urn:cap:decide:mint";
pub const CAP_DECIDE_ACCEPT: &str = "urn:cap:decide:accept";
pub const DECISIONS_SPACE: &str = "urn:space:decisions";
const SIGNING_SECRET: &str = "urn:secret:booking-decide";
const TTL_SECONDS: i64 = 7 * 24 * 3600;
pub fn public_key_path() -> std::path::PathBuf {
file_root().join("decide.pub")
}
fn decide_base() -> String {
std::env::var("IKIGAI_DECIDE_BASE")
.unwrap_or_else(|_| "https://ikigai-rs.dev/calendar-request".to_string())
}
pub(crate) fn b64() -> base64::engine::general_purpose::GeneralPurpose {
base64::engine::general_purpose::URL_SAFE_NO_PAD
}
fn payload(id: &str, action: &str, exp: i64) -> String {
format!("{id}|{action}|{exp}")
}
fn known_action(action: &str) -> bool {
matches!(action, "approve" | "approve-zoom" | "decline" | "block")
}
fn id_shaped(id: &str) -> bool {
(8..=64).contains(&id.len())
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
fn mint_token(id: &str, action: &str, exp: i64, key: &SigningKey) -> String {
let sig = key.sign(payload(id, action, exp).as_bytes());
b64().encode(sig.to_bytes())
}
fn token_valid(
id: &str,
action: &str,
exp: i64,
token: &str,
key: &VerifyingKey,
now: i64,
) -> bool {
if !known_action(action) || !id_shaped(id) || exp <= now {
return false;
}
let Ok(raw) = b64().decode(token) else {
return false;
};
let Ok(bytes) = <[u8; 64]>::try_from(raw.as_slice()) else {
return false;
};
key.verify(
payload(id, action, exp).as_bytes(),
&Signature::from_bytes(&bytes),
)
.is_ok()
}
pub(crate) fn now_secs() -> i64 {
chrono::Utc::now().timestamp()
}
pub(crate) fn verifying_key_at(path: &std::path::Path) -> Result<VerifyingKey> {
let bytes = std::fs::read(path).map_err(|e| {
Error::Endpoint(format!(
"cannot read the decide public key at {}: {e}",
path.display()
))
})?;
parse_verifying_key(&bytes).map_err(|e| Error::Endpoint(format!("{}: {e}", path.display())))
}
fn parse_verifying_key(bytes: &[u8]) -> std::result::Result<VerifyingKey, String> {
if let Ok(pem) = std::str::from_utf8(bytes) {
if let Ok(key) = VerifyingKey::from_public_key_pem(pem.trim()) {
return Ok(key);
}
}
VerifyingKey::from_public_key_der(bytes).map_err(|e| format!("not an SPKI Ed25519 key: {e}"))
}
pub(crate) fn parse_signing_key(bytes: &[u8]) -> std::result::Result<SigningKey, String> {
if let Ok(pem) = std::str::from_utf8(bytes) {
if let Ok(key) = SigningKey::from_pkcs8_pem(pem.trim()) {
return Ok(key);
}
}
SigningKey::from_pkcs8_der(bytes).map_err(|e| format!("not a PKCS8 Ed25519 key: {e}"))
}
pub(crate) fn urlencode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for b in value.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn percent_decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
let hex = |b: u8| match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
};
match (hex(bytes[i + 1]), hex(bytes[i + 2])) {
(Some(hi), Some(lo)) => {
out.push((hi << 4) | lo);
i += 3;
}
_ => {
out.push(bytes[i]);
i += 1;
}
}
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
pub(crate) fn param(inv: &Invocation<'_>, name: &str) -> String {
if let Ok(v) = inv.inline_str(name) {
let v = v.trim();
if !v.is_empty() {
return v.to_string();
}
}
if let Ok(body) = inv.inline_str("content") {
for pair in body.trim().split('&') {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
if percent_decode(k) == name {
return percent_decode(v).trim().to_string();
}
}
}
String::new()
}
pub fn field(tuple: &str, name: &str) -> Option<String> {
let needle = format!("({name} \"");
let start = tuple.find(&needle)? + needle.len();
let mut out = String::new();
let mut chars = tuple[start..].chars();
while let Some(c) = chars.next() {
match c {
'\\' => out.push(chars.next()?),
'"' => return Some(out),
_ => out.push(c),
}
}
None
}
pub(crate) fn quoted(value: &str) -> String {
let mut out = String::from("\"");
for c in value.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
_ => out.push(c),
}
}
out.push('"');
out
}
pub struct DecideLink;
#[async_trait::async_trait]
impl Endpoint for DecideLink {
async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
if !inv.capability.allows(CAP_DECIDE_MINT) {
return Err(Error::Denied(format!(
"minting a decide link requires `{CAP_DECIDE_MINT}`"
)));
}
let id = inv
.inline_str("id")
.map_err(|_| Error::MissingArgument("id".to_string()))?
.trim()
.to_string();
if !id_shaped(&id) {
return Err(Error::InvalidArgument {
name: "id".to_string(),
detail: format!("`{id}` is not a booking id"),
});
}
let secret = inv
.issue(Request::new(
Verb::Source,
Iri::parse(SIGNING_SECRET).expect("literal IRI"),
))
.await?;
let key = parse_signing_key(&secret.bytes)
.map_err(|e| Error::Endpoint(format!("{SIGNING_SECRET} is not a signing key: {e}")))?;
let exp = now_secs() + TTL_SECONDS;
let base = decide_base();
let mut out = String::new();
for action in ["approve", "decline", "block", "approve-zoom"] {
let token = mint_token(&id, action, exp, &key);
out.push_str(&format!(
"{base}/{action}?id={}&exp={exp}&t={}\n",
urlencode(&id),
urlencode(&token)
));
}
Ok(Representation::new(
ReprType::new("text/plain").with_param("charset", "utf-8"),
out.into_bytes(),
))
}
fn name(&self) -> &str {
"decide-link"
}
fn describe(&self) -> Description {
Description::new("decide-link")
.title("Mint the decide links")
.summary(
"Signed, expiring URLs for one pending booking — approve, decline, block, and \
approve-with-Zoom — one per line, ready to email.",
)
.action(
ActionSpec::new(Verb::Source)
.summary("mint — the signed links for a pending booking")
.requires(CAP_DECIDE_MINT)
.input(ArgSpec::new("id").summary("the pending booking's tuple id")),
)
.output("text/plain; charset=utf-8")
}
}
pub struct CalendarRequest {
pub key_path: std::path::PathBuf,
}
pub(crate) fn page(title: &str, body: &str) -> Representation {
Representation::new(
ReprType::new("text/html").with_param("charset", "utf-8"),
format!(
"<!doctype html><meta name=viewport content=\"width=device-width,initial-scale=1\">\
<title>{title}</title>\
<body style=\"font:16px/1.5 system-ui;margin:3rem auto;max-width:32rem;padding:0 1rem\">\
<h1 style=\"font-size:1.3rem\">{title}</h1>{body}"
)
.into_bytes(),
)
}
fn action_of(inv: &Invocation<'_>) -> String {
inv.request
.target
.as_str()
.rsplit(':')
.next()
.unwrap_or_default()
.to_string()
}
#[async_trait::async_trait]
impl Endpoint for CalendarRequest {
async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
let action = action_of(inv);
let (id, token) = (param(inv, "id"), param(inv, "t"));
let exp: i64 = param(inv, "exp").parse().unwrap_or(0);
let key = verifying_key_at(&self.key_path)?;
if !token_valid(&id, &action, exp, &token, &key, now_secs()) {
return Ok(page(
"That link didn't work",
"<p>It may have expired, or already been used. You can still decide it on \
your Mac.</p>",
));
}
match inv.request.verb {
Verb::Source => {
let (verb_word, note) = match action.as_str() {
"approve" => (
"Approve",
"The invitation goes out once your Mac picks this up.",
),
"approve-zoom" => (
"Approve + Zoom",
"A Zoom is scheduled and the invitation — with the join link — goes out \
once your Mac picks this up.",
),
"block" => (
"Block",
"This and future requests from that address are dropped, silently.",
),
_ => (
"Decline",
"They'll be told, and asked to suggest other times.",
),
};
Ok(page(
&format!("{verb_word} this request?"),
&format!(
"<p>Booking <code>{id}</code>.</p>\
<form method=\"post\" id=\"act\">\
<input type=hidden name=id value=\"{id}\">\
<input type=hidden name=exp value=\"{exp}\">\
<input type=hidden name=t value=\"{token}\">\
<button style=\"font:inherit;padding:.6rem 1.2rem\">{verb_word}</button>\
</form><p style=\"color:#666\">{note}</p>{js}",
js = crate::passkey::DECISION_PASSKEY_JS,
),
))
}
Verb::Sink => {
if crate::passkey::require_passkey(inv).is_err() {
return Ok(page(
"Your passkey is needed",
"<p>This decision needs a tap on your registered device. Reopen the \
link and try again.</p>",
));
}
let tuple = format!(
"((decide {}) (id {}) (exp {}) (token {}))",
quoted(&action),
quoted(&id),
quoted(&exp.to_string()),
quoted(&token)
);
inv.issue(
Request::new(
Verb::Sink,
Iri::parse(DECISIONS_SPACE).expect("literal IRI"),
)
.with_arg("content", ArgRef::Inline(tuple.into_bytes())),
)
.await?;
let what = match action.as_str() {
"approve" => "Approved — the invitation goes out once your Mac picks this up.",
"approve-zoom" => {
"Approved with Zoom — a meeting is scheduled and the invitation, with the \
join link, goes out once your Mac picks this up."
}
"block" => "Blocked — this and future requests from that address are dropped.",
_ => "Declined — they'll be told, and asked to suggest other times.",
};
Ok(page("Recorded", &format!("<p>{what}</p>")))
}
other => Err(Error::Endpoint(format!(
"a calendar request is shown with Source or decided with Sink, not {other:?}"
))),
}
}
fn name(&self) -> &str {
"calendar-request"
}
fn describe(&self) -> Description {
Description::new("calendar-request")
.title("Approve or decline a booking request")
.summary(
"The emailed decision link. GET verifies the token and shows what it would \
do; POST records the decision for the host to act on. Records intent only — \
nothing is scheduled or cancelled here.",
)
.action(
ActionSpec::new(Verb::Source)
.summary("show — what this link would decide")
.input(ArgSpec::new("id").summary("the booking id"))
.input(ArgSpec::new("exp").summary("expiry, unix seconds"))
.input(ArgSpec::new("t").summary("the signature")),
)
.action(
ActionSpec::new(Verb::Sink)
.summary("decide — record the decision")
.input(ArgSpec::new("id").summary("the booking id"))
.input(ArgSpec::new("exp").summary("expiry, unix seconds"))
.input(ArgSpec::new("t").summary("the signature")),
)
.output("text/html; charset=utf-8")
}
}
pub struct DecideAccept {
pub key_path: std::path::PathBuf,
}
#[async_trait::async_trait]
impl Endpoint for DecideAccept {
async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
if !inv.capability.allows(CAP_DECIDE_ACCEPT) {
return Err(Error::Denied(format!(
"acting on a decision requires `{CAP_DECIDE_ACCEPT}`"
)));
}
let tuple = inv
.inline_str("content")
.map_err(|_| Error::MissingArgument("content".to_string()))?;
let get = |name: &str| field(tuple, name).unwrap_or_default();
let (action, id, token) = (get("decide"), get("id"), get("token"));
let exp: i64 = get("exp").parse().unwrap_or(0);
let key = verifying_key_at(&self.key_path)?;
if !token_valid(&id, &action, exp, &token, &key, now_secs()) {
return Err(Error::Denied(format!(
"decision for `{id}` is not signed by this host — refusing to act on it"
)));
}
let command = format!("({action} \"{id}\")");
let out = inv
.issue(
Request::new(
Verb::Sink,
Iri::parse("urn:booking:confirm").expect("literal IRI"),
)
.with_arg("content", ArgRef::Inline(command.into_bytes())),
)
.await?;
Ok(out)
}
fn name(&self) -> &str {
"decide-accept"
}
fn describe(&self) -> Description {
Description::new("decide-accept")
.title("Act on a decision from the edge")
.summary(
"Re-verifies a drained decision against this host's own key, then runs the \
booking confirmation. A decision this host did not sign is refused.",
)
.action(
ActionSpec::new(Verb::Sink)
.summary("accept — verify a decision tuple and run confirm")
.requires(CAP_DECIDE_ACCEPT)
.input(ArgSpec::new("content").summary("the decision tuple")),
)
.output("text/plain; charset=utf-8")
}
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
fn key() -> SigningKey {
SigningKey::from_bytes(&[7u8; 32])
}
const ID: &str = "abc123def456";
const NOW: i64 = 1_800_000_000;
const EXP: i64 = NOW + 600;
#[test]
fn a_freshly_minted_token_verifies() {
let k = key();
let t = mint_token(ID, "approve", EXP, &k);
assert!(token_valid(ID, "approve", EXP, &t, &k.verifying_key(), NOW));
}
#[test]
fn approve_zoom_is_a_known_action_whose_token_round_trips_and_does_not_forge() {
assert!(known_action("approve-zoom"));
let k = key();
let v = k.verifying_key();
let t = mint_token(ID, "approve-zoom", EXP, &k);
assert!(token_valid(ID, "approve-zoom", EXP, &t, &v, NOW));
let a = mint_token(ID, "approve", EXP, &k);
assert!(!token_valid(ID, "approve-zoom", EXP, &a, &v, NOW));
}
#[test]
fn a_token_is_bound_to_its_booking_and_its_action() {
let k = key();
let v = k.verifying_key();
let t = mint_token(ID, "approve", EXP, &k);
assert!(!token_valid("zzz999zzz999", "approve", EXP, &t, &v, NOW));
assert!(!token_valid(ID, "decline", EXP, &t, &v, NOW));
assert!(!token_valid(ID, "approve", EXP + 1, &t, &v, NOW));
}
#[test]
fn an_expired_token_is_refused() {
let k = key();
let t = mint_token(ID, "approve", EXP, &k);
assert!(!token_valid(
ID,
"approve",
EXP,
&t,
&k.verifying_key(),
EXP + 1
));
}
#[test]
fn another_key_cannot_sign_a_decision() {
let theirs = SigningKey::from_bytes(&[9u8; 32]);
let t = mint_token(ID, "approve", EXP, &theirs);
assert!(!token_valid(
ID,
"approve",
EXP,
&t,
&key().verifying_key(),
NOW
));
}
#[test]
fn garbage_tokens_are_refused_without_panicking() {
let v = key().verifying_key();
for bad in ["", "!!!!", "c2hvcnQ", &"A".repeat(200)] {
assert!(!token_valid(ID, "approve", EXP, bad, &v, NOW), "{bad}");
}
}
#[test]
fn an_unknown_action_or_malformed_id_never_reaches_the_signature_check() {
let k = key();
let v = k.verifying_key();
let t = mint_token(ID, "approve", EXP, &k);
assert!(!token_valid(ID, "delete-everything", EXP, &t, &v, NOW));
assert!(!token_valid(
"../../etc/passwd",
"approve",
EXP,
&t,
&v,
NOW
));
assert!(!token_valid("short", "approve", EXP, &t, &v, NOW));
}
#[test]
fn fields_come_back_out_of_a_decision_tuple() {
let tuple = format!(
"((decide {}) (id {}) (exp {}) (token {}))",
quoted("approve"),
quoted(ID),
quoted("1800000600"),
quoted("sig==")
);
assert_eq!(field(&tuple, "decide").as_deref(), Some("approve"));
assert_eq!(field(&tuple, "id").as_deref(), Some(ID));
assert_eq!(field(&tuple, "token").as_deref(), Some("sig=="));
assert_eq!(field(&tuple, "nope"), None);
}
#[test]
fn an_escaped_value_survives_the_round_trip() {
let nasty = r#"a") (id "evil"#;
let tuple = format!("((decide {}) (id {}))", quoted("approve"), quoted(nasty));
assert_eq!(field(&tuple, "id").as_deref(), Some(nasty));
}
}
#[cfg(test)]
mod endpoint_tests {
use super::*;
use futures::executor::block_on;
use ikigai_core::{Capability, EndpointSpace, Exact, Kernel, UriTemplate};
use std::sync::{Arc, Mutex};
#[derive(Clone, Default)]
struct Recorder {
dropped: Arc<Mutex<Vec<String>>>,
}
#[async_trait::async_trait]
impl Endpoint for Recorder {
async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
self.dropped
.lock()
.unwrap()
.push(inv.inline_str("content").unwrap_or("").to_string());
Ok(Representation::new(
ReprType::new("text/plain"),
b"ok".to_vec(),
))
}
fn name(&self) -> &str {
"decisions"
}
fn describe(&self) -> Description {
Description::new("decisions").verb(Verb::Sink)
}
}
struct World {
kernel: Kernel,
dropped: Arc<Mutex<Vec<String>>>,
key: SigningKey,
_dir: std::path::PathBuf,
}
fn world(name: &str) -> World {
let dir = std::env::temp_dir().join(format!("ikigai-decide-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let key = SigningKey::from_bytes(&[7u8; 32]);
let pem = {
use ed25519_dalek::pkcs8::EncodePublicKey;
key.verifying_key()
.to_public_key_pem(ed25519_dalek::pkcs8::spki::der::pem::LineEnding::LF)
.unwrap()
};
let key_path = dir.join("decide.pub");
std::fs::write(&key_path, pem).unwrap();
let recorder = Recorder::default();
let kernel = Kernel::new(Arc::new(
EndpointSpace::new()
.bind(
UriTemplate::parse("urn:calendar-request:{action}").unwrap(),
CalendarRequest {
key_path: key_path.clone(),
},
)
.bind(Exact::new(DECISIONS_SPACE), recorder.clone()),
));
World {
kernel,
dropped: recorder.dropped,
key,
_dir: dir,
}
}
const ID: &str = "abc123def456";
impl World {
fn call(&self, verb: Verb, action: &str, id: &str, exp: i64, token: &str) -> String {
let rep = block_on(
self.kernel.issue(
Request::new(
verb,
Iri::parse(format!("urn:calendar-request:{action}")).unwrap(),
)
.with_arg("id", ArgRef::Inline(id.as_bytes().to_vec()))
.with_arg("exp", ArgRef::Inline(exp.to_string().into_bytes()))
.with_arg("t", ArgRef::Inline(token.as_bytes().to_vec())),
&Capability::root(),
),
)
.expect("the page should render");
String::from_utf8(rep.bytes.clone()).unwrap()
}
fn valid(&self, action: &str) -> (i64, String) {
let exp = now_secs() + 600;
(exp, mint_token(ID, action, exp, &self.key))
}
fn dropped(&self) -> Vec<String> {
self.dropped.lock().unwrap().clone()
}
fn post_form(&self, action: &str, id: &str, exp: i64, token: &str) -> String {
let body = format!("id={id}&exp={exp}&t={token}");
let rep = block_on(
self.kernel.issue(
Request::new(
Verb::Sink,
Iri::parse(format!("urn:calendar-request:{action}")).unwrap(),
)
.with_arg("content", ArgRef::Inline(body.into_bytes())),
&Capability::root(),
),
)
.expect("the page should render");
String::from_utf8(rep.bytes.clone()).unwrap()
}
}
#[test]
fn a_get_shows_the_decision_but_records_nothing() {
let w = world("get");
let (exp, token) = w.valid("approve");
let html = w.call(Verb::Source, "approve", ID, exp, &token);
assert!(html.contains("Approve this request?"), "{html}");
assert!(
html.contains("method=\"post\""),
"offers a POST button: {html}"
);
assert!(w.dropped().is_empty(), "a GET must record nothing");
}
#[test]
fn a_post_records_the_decision_with_its_token() {
let w = world("post");
let (exp, token) = w.valid("approve");
let html = w.call(Verb::Sink, "approve", ID, exp, &token);
assert!(html.contains("Recorded"), "{html}");
let dropped = w.dropped();
assert_eq!(dropped.len(), 1, "{dropped:?}");
assert_eq!(field(&dropped[0], "decide").as_deref(), Some("approve"));
assert_eq!(field(&dropped[0], "id").as_deref(), Some(ID));
assert_eq!(field(&dropped[0], "token").as_deref(), Some(token.as_str()));
}
#[test]
fn decline_is_its_own_resource() {
let w = world("decline");
let (exp, token) = w.valid("decline");
assert!(w
.call(Verb::Sink, "decline", ID, exp, &token)
.contains("Recorded"));
assert_eq!(field(&w.dropped()[0], "decide").as_deref(), Some("decline"));
}
#[test]
fn a_token_minted_for_approve_cannot_post_a_decline() {
let w = world("swap");
let (exp, token) = w.valid("approve");
let html = w.call(Verb::Sink, "decline", ID, exp, &token);
assert!(html.contains("didn't work"), "{html}");
assert!(w.dropped().is_empty(), "nothing recorded");
}
#[test]
fn a_forged_or_expired_token_records_nothing() {
let w = world("forged");
let (exp, _) = w.valid("approve");
let forged = mint_token(ID, "approve", exp, &SigningKey::from_bytes(&[9u8; 32]));
assert!(w
.call(Verb::Sink, "approve", ID, exp, &forged)
.contains("didn't work"));
let stale = now_secs() - 1;
let stale_token = mint_token(ID, "approve", stale, &w.key);
assert!(w
.call(Verb::Sink, "approve", ID, stale, &stale_token)
.contains("didn't work"));
assert!(w.dropped().is_empty(), "neither is recorded");
}
#[test]
fn a_browser_form_post_carries_its_fields_in_the_body() {
let w = world("formpost");
let (exp, token) = w.valid("approve");
let html = w.post_form("approve", ID, exp, &token);
assert!(html.contains("Recorded"), "{html}");
assert_eq!(w.dropped().len(), 1);
assert_eq!(field(&w.dropped()[0], "id").as_deref(), Some(ID));
}
#[test]
fn a_forged_token_in_a_form_body_is_refused_too() {
let w = world("formforged");
let (exp, _) = w.valid("approve");
let forged = mint_token(ID, "approve", exp, &SigningKey::from_bytes(&[9u8; 32]));
assert!(w
.post_form("approve", ID, exp, &forged)
.contains("didn't work"));
assert!(w.dropped().is_empty());
}
}