1use 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
24pub const TOKEN_VERSION: u8 = 1;
26const PREFIX: &str = "f1.";
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30struct Claims {
31 v: u8,
33 sub: String,
35 own: String,
37 iat: u64,
39 exp: u64,
41}
42
43#[derive(Debug, thiserror::Error, PartialEq, Eq)]
45pub enum TokenError {
46 #[error("malformed token")]
48 Malformed,
49 #[error("bad signature")]
51 BadMac,
52 #[error("token expired")]
54 Expired,
55 #[error("unsupported token version")]
57 UnsupportedVersion,
58 #[error("invalid key")]
60 Key,
61}
62
63#[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
86pub 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 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 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 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}