Skip to main content

solid_pod_rs_forge/
token.rs

1//! Forge push token — a macaroon-lite HMAC bearer.
2//!
3//! Wire format: `f1.<b64url(json claims)>.<b64url(HMAC-SHA256)>`. The MAC
4//! covers the `f1.<claims>` prefix so neither the version tag nor the
5//! claims can be swapped without invalidating it. Verification is
6//! constant-time (via [`hmac::Mac::verify_slice`]) and checks version +
7//! expiry. Cites JSS `forge` `token.js` behaviour by name only; the
8//! implementation is original.
9//!
10//! A token is minted from an already-authenticated caller (NIP-98 or a
11//! pod session) so subsequent forge requests present the cheap bearer
12//! instead of re-signing every call.
13
14use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
15use base64::Engine;
16use hmac::{Hmac, Mac};
17use serde::{Deserialize, Serialize};
18use sha2::Sha256;
19
20use crate::ownership::ForgeAgent;
21
22type HmacSha256 = Hmac<Sha256>;
23
24/// Current token format version.
25pub const TOKEN_VERSION: u8 = 1;
26const PREFIX: &str = "f1.";
27
28/// Decoded token claims.
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30struct Claims {
31    /// Format version (must be [`TOKEN_VERSION`]).
32    v: u8,
33    /// Subject — the author id (WebID or `did:nostr:<hex>`).
34    sub: String,
35    /// Owner namespace (pod username or 64-hex pubkey).
36    own: String,
37    /// Issued-at, Unix seconds.
38    iat: u64,
39    /// Expiry, Unix seconds.
40    exp: u64,
41}
42
43/// Token verify/mint failure modes.
44#[derive(Debug, thiserror::Error, PartialEq, Eq)]
45pub enum TokenError {
46    /// Not a `f1.*` token or the wrong number of segments.
47    #[error("malformed token")]
48    Malformed,
49    /// The HMAC did not verify.
50    #[error("bad signature")]
51    BadMac,
52    /// `exp` is in the past.
53    #[error("token expired")]
54    Expired,
55    /// Unsupported version tag.
56    #[error("unsupported token version")]
57    UnsupportedVersion,
58    /// The HMAC key was empty/invalid.
59    #[error("invalid key")]
60    Key,
61}
62
63/// Mint a token for `agent`, valid for `ttl` seconds from `iat`. Returns
64/// `None` for an anonymous caller (nothing to authorise).
65#[must_use]
66pub fn mint(key: &[u8], agent: &ForgeAgent, iat: u64, ttl: u64) -> Option<String> {
67    let (sub, own) = match agent {
68        ForgeAgent::Pod { webid, username } => (webid.clone(), username.clone()),
69        ForgeAgent::Nostr { pubkey_hex } => (format!("did:nostr:{pubkey_hex}"), pubkey_hex.clone()),
70        ForgeAgent::Anonymous => return None,
71    };
72    let claims = Claims {
73        v: TOKEN_VERSION,
74        sub,
75        own,
76        iat,
77        exp: iat.saturating_add(ttl),
78    };
79    let claims_json = serde_json::to_vec(&claims).ok()?;
80    let claims_b64 = B64.encode(claims_json);
81    let signing_input = format!("{PREFIX}{claims_b64}");
82    let mac = mac_tag(key, signing_input.as_bytes())?;
83    Some(format!("{signing_input}.{}", B64.encode(mac)))
84}
85
86/// Verify a token at `now`, returning the reconstructed caller identity.
87pub fn verify(key: &[u8], token: &str, now: u64) -> Result<ForgeAgent, TokenError> {
88    let rest = token.strip_prefix(PREFIX).ok_or(TokenError::Malformed)?;
89    let (claims_b64, mac_b64) = rest.split_once('.').ok_or(TokenError::Malformed)?;
90    if claims_b64.is_empty() || mac_b64.is_empty() {
91        return Err(TokenError::Malformed);
92    }
93
94    // Recompute + constant-time compare over the exact signing input.
95    let signing_input = format!("{PREFIX}{claims_b64}");
96    let provided = B64.decode(mac_b64).map_err(|_| TokenError::Malformed)?;
97    let mut mac = HmacSha256::new_from_slice(key).map_err(|_| TokenError::Key)?;
98    mac.update(signing_input.as_bytes());
99    mac.verify_slice(&provided)
100        .map_err(|_| TokenError::BadMac)?;
101
102    // MAC is valid → decode and check the claims.
103    let claims_json = B64.decode(claims_b64).map_err(|_| TokenError::Malformed)?;
104    let claims: Claims = serde_json::from_slice(&claims_json).map_err(|_| TokenError::Malformed)?;
105    if claims.v != TOKEN_VERSION {
106        return Err(TokenError::UnsupportedVersion);
107    }
108    if claims.exp <= now {
109        return Err(TokenError::Expired);
110    }
111
112    Ok(
113        if crate::ownership::classify_owner(&claims.own) == crate::ownership::OwnerKind::NostrHex {
114            ForgeAgent::Nostr {
115                pubkey_hex: claims.own,
116            }
117        } else {
118            ForgeAgent::Pod {
119                webid: claims.sub,
120                username: claims.own,
121            }
122        },
123    )
124}
125
126fn mac_tag(key: &[u8], msg: &[u8]) -> Option<Vec<u8>> {
127    let mut mac = HmacSha256::new_from_slice(key).ok()?;
128    mac.update(msg);
129    Some(mac.finalize().into_bytes().to_vec())
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    const KEY: &[u8] = b"test-hmac-key-32-bytes-exactly!!";
137
138    fn nostr() -> ForgeAgent {
139        ForgeAgent::Nostr {
140            pubkey_hex: "a".repeat(64),
141        }
142    }
143    fn pod() -> ForgeAgent {
144        ForgeAgent::Pod {
145            webid: "https://pod/alice/card#me".into(),
146            username: "alice".into(),
147        }
148    }
149
150    #[test]
151    fn mint_verify_roundtrip_nostr() {
152        let t = mint(KEY, &nostr(), 1000, 3600).unwrap();
153        assert!(t.starts_with("f1."));
154        let agent = verify(KEY, &t, 1500).unwrap();
155        assert_eq!(agent, nostr());
156    }
157
158    #[test]
159    fn mint_verify_roundtrip_pod() {
160        let t = mint(KEY, &pod(), 1000, 3600).unwrap();
161        let agent = verify(KEY, &t, 1500).unwrap();
162        assert_eq!(agent, pod());
163    }
164
165    #[test]
166    fn anonymous_mints_nothing() {
167        assert!(mint(KEY, &ForgeAgent::Anonymous, 0, 1).is_none());
168    }
169
170    #[test]
171    fn expired_token_rejected() {
172        let t = mint(KEY, &nostr(), 1000, 100).unwrap();
173        assert_eq!(verify(KEY, &t, 2000), Err(TokenError::Expired));
174    }
175
176    #[test]
177    fn tampered_claims_fail_mac() {
178        let t = mint(KEY, &nostr(), 1000, 3600).unwrap();
179        // Flip a char in the claims section.
180        let (head, tail) = t.split_once('.').unwrap();
181        let (claims, mac) = tail.split_once('.').unwrap();
182        let mut cb = claims.as_bytes().to_vec();
183        cb[0] ^= 0x01;
184        let tampered = format!("{head}.{}.{mac}", String::from_utf8_lossy(&cb));
185        assert!(matches!(
186            verify(KEY, &tampered, 1500),
187            Err(TokenError::BadMac) | Err(TokenError::Malformed)
188        ));
189    }
190
191    #[test]
192    fn wrong_key_fails_mac() {
193        let t = mint(KEY, &nostr(), 1000, 3600).unwrap();
194        assert_eq!(
195            verify(b"another-key-entirely-different!!", &t, 1500),
196            Err(TokenError::BadMac)
197        );
198    }
199
200    #[test]
201    fn malformed_tokens_rejected() {
202        assert_eq!(verify(KEY, "nope", 0), Err(TokenError::Malformed));
203        assert_eq!(verify(KEY, "f1.only-one", 0), Err(TokenError::Malformed));
204        assert_eq!(verify(KEY, "f1..", 0), Err(TokenError::Malformed));
205    }
206}