irongit

Git hosting and a container registry in one Rust binary (axum + Astro)

irongit/backend/src/signed.rs
64 lines2.4 KBRust
1//! Short-lived signed values: registry bearer tokens, LFS tokens handed out
2//! over SSH, the OAuth state cookie. `payload.signature`, both base64url,
3//! HMAC-SHA256 with SECRET_KEY. A `purpose` string is mixed into the MAC so a
4//! token minted for one use can never be replayed as another.
5
6use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
7use hmac::{KeyInit, Mac};
8use serde::{Serialize, de::DeserializeOwned};
9
10type HmacSha256 = hmac::Hmac<sha2::Sha256>;
11
12#[derive(serde::Serialize, serde::Deserialize)]
13struct Envelope<T> {
14 exp: i64,
15 data: T,
16}
17
18fn mac(key: &[u8], purpose: &str, payload: &str) -> Vec<u8> {
19 let mut mac = HmacSha256::new_from_slice(key).expect("hmac accepts any key length");
20 mac.update(purpose.as_bytes());
21 mac.update(b"\0");
22 mac.update(payload.as_bytes());
23 mac.finalize().into_bytes().to_vec()
24}
25
26pub fn sign<T: Serialize>(key: &[u8], purpose: &str, data: &T, ttl_secs: i64) -> String {
27 let envelope = Envelope { exp: chrono::Utc::now().timestamp() + ttl_secs, data };
28 let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&envelope).expect("serializable"));
29 let signature = URL_SAFE_NO_PAD.encode(mac(key, purpose, &payload));
30 format!("{payload}.{signature}")
31}
32
33pub fn verify<T: DeserializeOwned>(key: &[u8], purpose: &str, token: &str) -> Option<T> {
34 let (payload, signature) = token.split_once('.')?;
35 let signature = URL_SAFE_NO_PAD.decode(signature).ok()?;
36 let mut check = HmacSha256::new_from_slice(key).ok()?;
37 check.update(purpose.as_bytes());
38 check.update(b"\0");
39 check.update(payload.as_bytes());
40 check.verify_slice(&signature).ok()?;
41 let envelope: Envelope<T> = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).ok()?).ok()?;
42 (envelope.exp > chrono::Utc::now().timestamp()).then_some(envelope.data)
43}
44
45#[cfg(test)]
46mod tests {
47 use super::*;
48
49 #[test]
50 fn roundtrip_and_purpose_binding() {
51 let key = [7u8; 32];
52 let token = sign(&key, "lfs", &("alice", 3), 60);
53 assert_eq!(verify::<(String, i32)>(&key, "lfs", &token), Some(("alice".into(), 3)));
54 assert_eq!(verify::<(String, i32)>(&key, "registry", &token), None);
55 assert_eq!(verify::<(String, i32)>(&[8u8; 32], "lfs", &token), None);
56 }
57
58 #[test]
59 fn expired_tokens_fail() {
60 let key = [1u8; 32];
61 let token = sign(&key, "x", &1, -5);
62 assert_eq!(verify::<i32>(&key, "x", &token), None);
63 }
64}