kagi_sync/domain/
envelope.rs1use 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}