Skip to main content

okbot_peer/
token.rs

1//! Signed request and response tokens (`okp1`, ADR 0014).
2//!
3//! `okp1.<base64url(claims JSON)>.<base64url(Ed25519 signature of "okp1." + claims part)>`. The
4//! `body` claim binds the token to one request or response body.
5
6use std::collections::HashMap;
7use std::sync::Mutex;
8
9use base64::Engine as _;
10use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
11use jiff::Timestamp;
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14
15use crate::Error;
16use crate::key::{PublicKey, SigningKey};
17
18const VERSION: &str = "okp1";
19/// The longest a token may live.
20pub const MAX_LIFETIME_SECS: i64 = 300;
21/// Clock difference tolerated between bots.
22pub const SKEW_SECS: i64 = 60;
23
24/// What a token says.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct Claims {
27    /// The team.
28    pub team: String,
29    /// The signing member.
30    pub iss: String,
31    /// The member it is meant for.
32    pub aud: String,
33    /// Issued at (Unix seconds).
34    pub iat: i64,
35    /// Expires at (Unix seconds).
36    pub exp: i64,
37    /// A unique id (replays are refused).
38    pub jti: String,
39    /// Hex SHA-256 of the body.
40    pub body: String,
41    /// The signer's manifest version.
42    pub ver: u64,
43}
44
45/// Hex SHA-256 of `body`.
46pub fn body_hash(body: &[u8]) -> String {
47    hex::encode(Sha256::digest(body))
48}
49
50impl Claims {
51    /// Claims for a body sent now from `iss` to `aud`, valid for `MAX_LIFETIME_SECS`.
52    pub fn new(team: &str, iss: &str, aud: &str, ver: u64, body: &[u8], now: Timestamp) -> Self {
53        let iat = now.as_second();
54        Self {
55            team: team.to_string(),
56            iss: iss.to_string(),
57            aud: aud.to_string(),
58            iat,
59            exp: iat + MAX_LIFETIME_SECS,
60            jti: hex::encode(rand::random::<[u8; 16]>()),
61            body: body_hash(body),
62            ver,
63        }
64    }
65}
66
67/// Signs `claims`.
68pub fn sign(key: &SigningKey, claims: &Claims) -> String {
69    let payload = B64.encode(serde_json::to_vec(claims).expect("claims serialize"));
70    let signed = format!("{VERSION}.{payload}");
71    let signature = B64.encode(key.sign(signed.as_bytes()));
72    format!("{signed}.{signature}")
73}
74
75/// The claims of a token, before its signature is checked: they name the signer to look up.
76pub fn peek(token: &str) -> Result<Claims, Error> {
77    let bad = || Error::Token("malformed token".into());
78    let mut parts = token.trim().split('.');
79    let (Some(VERSION), Some(payload), Some(_), None) =
80        (parts.next(), parts.next(), parts.next(), parts.next())
81    else {
82        return Err(bad());
83    };
84    serde_json::from_slice(&B64.decode(payload).map_err(|_| bad())?).map_err(|_| bad())
85}
86
87/// Checks a token's signature by `signer`, its times and its body; returns its claims. Replays
88/// are checked separately ([`Replays`]), after the signer is known to be a member.
89pub fn verify(
90    token: &str,
91    signer: &PublicKey,
92    body: &[u8],
93    now: Timestamp,
94) -> Result<Claims, Error> {
95    let claims = peek(token)?;
96    let token = token.trim();
97    let (signed, signature) = token
98        .rsplit_once('.')
99        .ok_or_else(|| Error::Token("malformed token".into()))?;
100    let signature = B64
101        .decode(signature)
102        .map_err(|_| Error::Token("malformed signature".into()))?;
103    if !signer.verify(signed.as_bytes(), &signature) {
104        return Err(Error::Token("bad signature".into()));
105    }
106    let now = now.as_second();
107    if claims.exp <= claims.iat || claims.exp - claims.iat > MAX_LIFETIME_SECS {
108        return Err(Error::Token("invalid lifetime".into()));
109    }
110    if claims.iat > now + SKEW_SECS || claims.exp + SKEW_SECS < now {
111        return Err(Error::Token(
112            "expired or not yet valid (check the clocks)".into(),
113        ));
114    }
115    if claims.body != body_hash(body) {
116        return Err(Error::Token("the body does not match the token".into()));
117    }
118    Ok(claims)
119}
120
121/// Token ids seen until they expire, to refuse replays.
122#[derive(Debug, Default)]
123pub struct Replays(Mutex<HashMap<String, i64>>);
124
125impl Replays {
126    /// Records the token's id; `false` when it was seen before.
127    pub fn first_use(&self, claims: &Claims, now: Timestamp) -> bool {
128        let mut seen = self
129            .0
130            .lock()
131            .unwrap_or_else(std::sync::PoisonError::into_inner);
132        let now = now.as_second();
133        seen.retain(|_, exp| *exp + SKEW_SECS >= now);
134        let key = format!("{}/{}/{}", claims.team, claims.iss, claims.jti);
135        seen.insert(key, claims.exp).is_none()
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn now() -> Timestamp {
144        Timestamp::from_second(1_800_000_000).unwrap()
145    }
146
147    #[test]
148    fn signed_tokens_verify_and_bind_the_body() {
149        let (key, _) = SigningKey::generate().unwrap();
150        let claims = Claims::new("acme", "lead", "ops", 3, b"{}", now());
151        let token = sign(&key, &claims);
152        assert_eq!(peek(&token).unwrap(), claims);
153        assert_eq!(verify(&token, &key.public(), b"{}", now()).unwrap(), claims);
154        assert!(
155            verify(&token, &key.public(), b"{ }", now()).is_err(),
156            "body"
157        );
158        let (other, _) = SigningKey::generate().unwrap();
159        assert!(
160            verify(&token, &other.public(), b"{}", now()).is_err(),
161            "signer"
162        );
163    }
164
165    #[test]
166    fn times_are_checked_with_skew() {
167        let (key, _) = SigningKey::generate().unwrap();
168        let token = sign(&key, &Claims::new("t", "a", "b", 1, b"", now()));
169        let at = |secs: i64| {
170            now()
171                .checked_add(jiff::SignedDuration::from_secs(secs))
172                .unwrap()
173        };
174        assert!(verify(&token, &key.public(), b"", at(-SKEW_SECS)).is_ok());
175        assert!(verify(&token, &key.public(), b"", at(-SKEW_SECS - 1)).is_err());
176        assert!(
177            verify(
178                &token,
179                &key.public(),
180                b"",
181                at(MAX_LIFETIME_SECS + SKEW_SECS)
182            )
183            .is_ok()
184        );
185        assert!(
186            verify(
187                &token,
188                &key.public(),
189                b"",
190                at(MAX_LIFETIME_SECS + SKEW_SECS + 1)
191            )
192            .is_err()
193        );
194        let mut long = Claims::new("t", "a", "b", 1, b"", now());
195        long.exp = long.iat + MAX_LIFETIME_SECS + 1;
196        assert!(verify(&sign(&key, &long), &key.public(), b"", now()).is_err());
197    }
198
199    #[test]
200    fn altered_tokens_are_refused() {
201        let (key, _) = SigningKey::generate().unwrap();
202        let token = sign(&key, &Claims::new("t", "a", "b", 1, b"", now()));
203        let mut forged = Claims::new("t", "a", "admin", 1, b"", now());
204        forged.jti = peek(&token).unwrap().jti;
205        let payload = B64.encode(serde_json::to_vec(&forged).unwrap());
206        let signature = token.rsplit_once('.').unwrap().1;
207        let altered = format!("okp1.{payload}.{signature}");
208        assert!(verify(&altered, &key.public(), b"", now()).is_err());
209        assert!(peek("okp2.a.b").is_err());
210        assert!(peek("okp1.a").is_err());
211    }
212
213    #[test]
214    fn replays_are_refused() {
215        let claims = Claims::new("t", "a", "b", 1, b"", now());
216        let replays = Replays::default();
217        assert!(replays.first_use(&claims, now()));
218        assert!(!replays.first_use(&claims, now()));
219        let later = now()
220            .checked_add(jiff::SignedDuration::from_secs(
221                MAX_LIFETIME_SECS + SKEW_SECS + 1,
222            ))
223            .unwrap();
224        assert!(replays.first_use(&Claims::new("t", "a", "b", 1, b"", later), later));
225    }
226}