agent-pay 0.1.0

L402 + DID-signed invoices: agent-to-agent Lightning payments (Rust port of @p-vbordei/agent-pay)
Documentation
//! HMAC-signed macaroon-style L402 tokens.

use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::Sha256;

use crate::error::Error;
use crate::jcs::canonical_json;

pub const VERSION: &str = "agent-pay/0.1";

type HmacSha256 = Hmac<Sha256>;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenPayload {
    pub v: String,
    pub payment_hash: String,
    pub expires_at: String,
}

fn b64url(data: &[u8]) -> String {
    URL_SAFE_NO_PAD.encode(data)
}

fn b64url_decode(s: &str) -> Result<Vec<u8>, Error> {
    URL_SAFE_NO_PAD
        .decode(s)
        .map_err(|e| Error::Token(format!("base64url: {e}")))
}

pub async fn issue_token(
    payment_hash: &str,
    expires_at: &str,
    secret: &[u8],
) -> Result<String, Error> {
    let payload = json!({
        "v": VERSION,
        "payment_hash": payment_hash,
        "expires_at": expires_at,
    });
    let payload_bytes = canonical_json(&payload)?;
    let mut mac =
        HmacSha256::new_from_slice(secret).map_err(|e| Error::Token(format!("hmac key: {e}")))?;
    mac.update(&payload_bytes);
    let sig = mac.finalize().into_bytes();
    Ok(format!("{}.{}", b64url(&payload_bytes), b64url(&sig)))
}

pub async fn verify_token(token: &str, secret: &[u8]) -> Result<TokenPayload, Error> {
    let parts: Vec<&str> = token.split('.').collect();
    if parts.len() != 2 {
        return Err(Error::Token("token must have 2 parts".into()));
    }
    let payload_bytes = b64url_decode(parts[0])?;
    let got = b64url_decode(parts[1])?;
    let mut mac =
        HmacSha256::new_from_slice(secret).map_err(|e| Error::Token(format!("hmac key: {e}")))?;
    mac.update(&payload_bytes);
    mac.verify_slice(&got)
        .map_err(|_| Error::Token("token HMAC verification failed".into()))?;
    let payload: TokenPayload = serde_json::from_slice(&payload_bytes)
        .map_err(|e| Error::Token(format!("invalid token payload: {e}")))?;
    if payload.v != VERSION {
        return Err(Error::Token(format!(
            "unsupported token version: {}",
            payload.v
        )));
    }
    Ok(payload)
}