kagi_sync/infrastructure/
remote_envelope.rs1#[cfg(feature = "server")]
2use crate::domain::envelope::SuccessResponse;
3use crate::domain::envelope::{RequestEnvelope, RequestPlaintext};
4use age::{Decryptor, Encryptor, x25519};
5use base64::{Engine as _, engine::general_purpose};
6use kagi_domain::error::DomainError;
7use std::io::{Read, Write};
8use std::str::FromStr;
9
10pub fn encrypt_request(
11 plaintext: &RequestPlaintext,
12 server_recipient: &x25519::Recipient,
13 response_recipient: &x25519::Recipient,
14) -> Result<RequestEnvelope, DomainError> {
15 let mut plaintext_value = serde_json::to_value(plaintext)?;
16 let response_recipient = response_recipient.to_string();
17 let object = plaintext_value.as_object_mut().ok_or_else(|| {
18 DomainError::EncryptFailed("request plaintext must serialize to an object".into())
19 })?;
20 object.insert(
21 "response_recipient".to_string(),
22 serde_json::Value::String(response_recipient.clone()),
23 );
24 let plaintext_json = serde_json::to_vec(&plaintext_value)?;
25 let ciphertext = encrypt_age(&plaintext_json, server_recipient)?;
26 Ok(RequestEnvelope {
27 version: 1,
28 request_id: plaintext.request_id.clone(),
29 server_key_id: "kgs_placeholder".to_string(),
30 response_recipient,
31 ciphertext: general_purpose::STANDARD.encode(&ciphertext),
32 })
33}
34
35#[cfg(feature = "server")]
36pub fn decrypt_request(
37 envelope: &RequestEnvelope,
38 server_identity: &x25519::Identity,
39) -> Result<RequestPlaintext, DomainError> {
40 let ciphertext = general_purpose::STANDARD
41 .decode(&envelope.ciphertext)
42 .map_err(|e| DomainError::DecryptFailed(e.to_string()))?;
43 let plaintext_bytes = decrypt_age(&ciphertext, server_identity)?;
44 let plaintext: RequestPlaintext = serde_json::from_slice(&plaintext_bytes)
45 .map_err(|e| DomainError::DecryptFailed(e.to_string()))?;
46 Ok(plaintext)
47}
48
49#[cfg(feature = "server")]
50pub fn encrypt_response(
51 data: &SuccessResponse,
52 recipient: &x25519::Recipient,
53) -> Result<Vec<u8>, DomainError> {
54 let plaintext = serde_json::to_vec(data)?;
55 encrypt_age(&plaintext, recipient)
56}
57
58pub fn decrypt_response(
59 ciphertext: &[u8],
60 identity: &x25519::Identity,
61) -> Result<serde_json::Value, DomainError> {
62 let plaintext = decrypt_age(ciphertext, identity)?;
63 let value: serde_json::Value = serde_json::from_slice(&plaintext)
64 .map_err(|e| DomainError::DecryptFailed(e.to_string()))?;
65 Ok(value)
66}
67
68pub fn encrypt_bytes(
69 plaintext: &[u8],
70 recipient: &x25519::Recipient,
71) -> Result<Vec<u8>, DomainError> {
72 encrypt_age(plaintext, recipient)
73}
74
75pub fn decrypt_bytes(
76 ciphertext: &[u8],
77 identity: &x25519::Identity,
78) -> Result<Vec<u8>, DomainError> {
79 decrypt_age(ciphertext, identity)
80}
81
82fn encrypt_age(plaintext: &[u8], recipient: &x25519::Recipient) -> Result<Vec<u8>, DomainError> {
83 let encryptor = Encryptor::with_recipients(std::iter::once(recipient as _))
84 .map_err(|e| DomainError::EncryptFailed(e.to_string()))?;
85 let mut encrypted = Vec::new();
86 let mut writer = encryptor
87 .wrap_output(&mut encrypted)
88 .map_err(|e| DomainError::EncryptFailed(e.to_string()))?;
89 writer
90 .write_all(plaintext)
91 .map_err(|e| DomainError::EncryptFailed(e.to_string()))?;
92 writer
93 .finish()
94 .map_err(|e| DomainError::EncryptFailed(e.to_string()))?;
95 Ok(encrypted)
96}
97
98fn decrypt_age(encrypted: &[u8], identity: &x25519::Identity) -> Result<Vec<u8>, DomainError> {
99 let decryptor =
100 Decryptor::new(encrypted).map_err(|e| DomainError::DecryptFailed(e.to_string()))?;
101 let mut reader = decryptor
102 .decrypt(std::iter::once(identity as &dyn age::Identity))
103 .map_err(|e| DomainError::DecryptFailed(e.to_string()))?;
104 let mut decrypted = Vec::new();
105 reader
106 .read_to_end(&mut decrypted)
107 .map_err(|e| DomainError::DecryptFailed(e.to_string()))?;
108 Ok(decrypted)
109}
110
111pub fn parse_recipient(s: &str) -> Result<x25519::Recipient, DomainError> {
112 x25519::Recipient::from_str(s)
113 .map_err(|e| DomainError::RemoteProtocolError(format!("invalid recipient: {e}")))
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 #[cfg(feature = "server")]
120 use crate::domain::envelope::RequestPlaintext;
121 use age::x25519;
122
123 #[cfg(feature = "server")]
124 fn test_identities() -> (x25519::Identity, x25519::Recipient) {
125 let identity = x25519::Identity::generate();
126 let recipient = identity.to_public();
127 (identity, recipient)
128 }
129
130 #[test]
131 #[cfg(feature = "server")]
132 fn test_encrypt_decrypt_request_roundtrip() {
133 let (server_identity, server_recipient) = test_identities();
134 let (_client_identity, client_recipient) = test_identities();
135
136 let plaintext = RequestPlaintext {
137 version: 1,
138 request_id: "kgr_test".into(),
139 issued_at: "2026-01-01T00:00:00Z".into(),
140 operation: "push".into(),
141 method: "POST".into(),
142 path: "/v1/projects/kgp_test/push".into(),
143 project_id: Some("kgp_test".into()),
144 token: Some("test_token".into()),
145 claim_secret: None,
146 payload: serde_json::json!({"base_revision": 0}),
147 };
148
149 let envelope = encrypt_request(&plaintext, &server_recipient, &client_recipient).unwrap();
150 assert_eq!(envelope.request_id, "kgr_test");
151 assert_eq!(envelope.server_key_id, "kgs_placeholder");
152 assert!(!envelope.ciphertext.is_empty());
153
154 let decrypted = decrypt_request(&envelope, &server_identity).unwrap();
155 assert_eq!(decrypted.request_id, plaintext.request_id);
156 assert_eq!(decrypted.path, plaintext.path);
157 assert_eq!(decrypted.method, plaintext.method);
158 }
159
160 #[test]
161 #[cfg(feature = "server")]
162 fn test_encrypt_decrypt_response_roundtrip() {
163 let (client_identity, client_recipient) = test_identities();
164
165 let response = SuccessResponse {
166 ok: true,
167 request_id: "kgr_test".into(),
168 data: serde_json::json!({"revision": 42}),
169 };
170
171 let ciphertext = encrypt_response(&response, &client_recipient).unwrap();
172 assert!(!ciphertext.is_empty());
173
174 let decrypted = decrypt_response(&ciphertext, &client_identity).unwrap();
175 assert_eq!(decrypted["ok"], true);
176 assert_eq!(decrypted["request_id"], "kgr_test");
177 assert_eq!(decrypted["data"]["revision"], 42);
178 }
179
180 #[test]
181 #[cfg(feature = "server")]
182 fn test_decrypt_with_wrong_identity_fails() {
183 let (server_identity, server_recipient) = test_identities();
184 let (_wrong_identity, wrong_recipient) = test_identities();
185
186 let plaintext = RequestPlaintext {
187 version: 1,
188 request_id: "kgr_test".into(),
189 issued_at: "2026-01-01T00:00:00Z".into(),
190 operation: "test".into(),
191 method: "POST".into(),
192 path: "/v1/test".into(),
193 project_id: None,
194 token: None,
195 claim_secret: None,
196 payload: serde_json::json!({}),
197 };
198
199 let envelope = encrypt_request(&plaintext, &server_recipient, &wrong_recipient).unwrap();
200 assert!(decrypt_request(&envelope, &server_identity).is_ok());
201
202 let (_other_identity, other_recipient) = test_identities();
204 let envelope2 = encrypt_request(&plaintext, &other_recipient, &wrong_recipient).unwrap();
205 assert!(decrypt_request(&envelope2, &server_identity).is_err());
206 }
207
208 #[test]
209 fn test_parse_recipient_valid() {
210 let identity = x25519::Identity::generate();
211 let recipient = identity.to_public().to_string();
212 assert!(parse_recipient(&recipient).is_ok());
213 }
214
215 #[test]
216 fn test_parse_recipient_invalid() {
217 assert!(parse_recipient("not_a_recipient").is_err());
218 }
219}