Skip to main content

kagi_sync/domain/
envelope.rs

1use hmac::{Hmac, KeyInit, Mac};
2use serde::{Deserialize, Serialize};
3use sha2::Sha256;
4
5type HmacSha256 = Hmac<Sha256>;
6
7#[derive(Serialize, Deserialize, Debug, Clone)]
8pub struct RequestEnvelope {
9    pub version: u8,
10    pub request_id: String,
11    pub server_key_id: String,
12    pub response_recipient: String,
13    pub ciphertext: String,
14}
15
16#[derive(Serialize, Deserialize, Debug, Clone)]
17pub struct ResponseEnvelope {
18    pub version: u8,
19    pub request_id: String,
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub mac: Option<String>,
22    pub ciphertext: String,
23}
24
25#[derive(Serialize, Deserialize, Debug, Clone)]
26pub struct RequestPlaintext {
27    pub version: u8,
28    pub request_id: String,
29    pub issued_at: String,
30    pub operation: String,
31    pub method: String,
32    pub path: String,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub project_id: Option<String>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub token: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub claim_secret: Option<String>,
39    #[serde(flatten)]
40    pub payload: serde_json::Value,
41}
42
43#[cfg(feature = "server")]
44#[derive(Serialize, Deserialize, Debug, Clone)]
45pub struct SuccessResponse {
46    pub ok: bool,
47    pub request_id: String,
48    pub data: serde_json::Value,
49}
50
51pub fn response_mac(key: &str, request_id: &str, ciphertext: &str) -> String {
52    let mut mac = HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC accepts any key size");
53    mac.update(b"kagi-response-v1");
54    mac.update(request_id.as_bytes());
55    mac.update(ciphertext.as_bytes());
56    let result = mac.finalize().into_bytes();
57    base64_url_encode(&result)
58}
59
60pub fn verify_response_mac(key: &str, request_id: &str, ciphertext: &str, mac: &str) -> bool {
61    let Ok(expected) = base64_url_decode(mac) else {
62        return false;
63    };
64    let mut verifier =
65        HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC accepts any key size");
66    verifier.update(b"kagi-response-v1");
67    verifier.update(request_id.as_bytes());
68    verifier.update(ciphertext.as_bytes());
69    verifier.verify_slice(&expected).is_ok()
70}
71
72fn base64_url_encode(input: &[u8]) -> String {
73    use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
74    URL_SAFE_NO_PAD.encode(input)
75}
76
77fn base64_url_decode(input: &str) -> Result<Vec<u8>, base64::DecodeError> {
78    use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
79    URL_SAFE_NO_PAD.decode(input)
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn response_mac_roundtrip() {
88        let token = "kagi_proj_v1_test.secret";
89        let request_id = "kgr_test";
90        let ciphertext = "ciphertext";
91        let mac = response_mac(token, request_id, ciphertext);
92        assert!(verify_response_mac(token, request_id, ciphertext, &mac));
93    }
94
95    #[test]
96    fn response_mac_rejects_tampered_ciphertext() {
97        let token = "kagi_proj_v1_test.secret";
98        let request_id = "kgr_test";
99        let mac = response_mac(token, request_id, "ciphertext");
100        assert!(!verify_response_mac(token, request_id, "other", &mac));
101    }
102
103    #[test]
104    fn response_mac_rejects_wrong_token() {
105        let mac = response_mac("token-a", "kgr_test", "ciphertext");
106        assert!(!verify_response_mac(
107            "token-b",
108            "kgr_test",
109            "ciphertext",
110            &mac
111        ));
112    }
113}