Skip to main content

agent_pay/
token.rs

1//! HMAC-signed macaroon-style L402 tokens.
2
3use base64::engine::general_purpose::URL_SAFE_NO_PAD;
4use base64::Engine;
5use hmac::{Hmac, Mac};
6use serde::{Deserialize, Serialize};
7use serde_json::json;
8use sha2::Sha256;
9
10use crate::error::Error;
11use crate::jcs::canonical_json;
12
13pub const VERSION: &str = "agent-pay/0.1";
14
15type HmacSha256 = Hmac<Sha256>;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct TokenPayload {
19    pub v: String,
20    pub payment_hash: String,
21    pub expires_at: String,
22}
23
24fn b64url(data: &[u8]) -> String {
25    URL_SAFE_NO_PAD.encode(data)
26}
27
28fn b64url_decode(s: &str) -> Result<Vec<u8>, Error> {
29    URL_SAFE_NO_PAD
30        .decode(s)
31        .map_err(|e| Error::Token(format!("base64url: {e}")))
32}
33
34pub async fn issue_token(
35    payment_hash: &str,
36    expires_at: &str,
37    secret: &[u8],
38) -> Result<String, Error> {
39    let payload = json!({
40        "v": VERSION,
41        "payment_hash": payment_hash,
42        "expires_at": expires_at,
43    });
44    let payload_bytes = canonical_json(&payload)?;
45    let mut mac =
46        HmacSha256::new_from_slice(secret).map_err(|e| Error::Token(format!("hmac key: {e}")))?;
47    mac.update(&payload_bytes);
48    let sig = mac.finalize().into_bytes();
49    Ok(format!("{}.{}", b64url(&payload_bytes), b64url(&sig)))
50}
51
52pub async fn verify_token(token: &str, secret: &[u8]) -> Result<TokenPayload, Error> {
53    let parts: Vec<&str> = token.split('.').collect();
54    if parts.len() != 2 {
55        return Err(Error::Token("token must have 2 parts".into()));
56    }
57    let payload_bytes = b64url_decode(parts[0])?;
58    let got = b64url_decode(parts[1])?;
59    let mut mac =
60        HmacSha256::new_from_slice(secret).map_err(|e| Error::Token(format!("hmac key: {e}")))?;
61    mac.update(&payload_bytes);
62    mac.verify_slice(&got)
63        .map_err(|_| Error::Token("token HMAC verification failed".into()))?;
64    let payload: TokenPayload = serde_json::from_slice(&payload_bytes)
65        .map_err(|e| Error::Token(format!("invalid token payload: {e}")))?;
66    if payload.v != VERSION {
67        return Err(Error::Token(format!(
68            "unsupported token version: {}",
69            payload.v
70        )));
71    }
72    Ok(payload)
73}