1use gate4agent_node_protocol::{
2 encode_node_compatibility_auth_binding, ClientCompatibilityOffer, ClientRole,
3 NegotiatedNodeCompatibility, NodeIncarnationId, BUILD_STAMP, NODE_AUTH_NONCE_BYTES,
4 NODE_AUTH_PROOF_BYTES, NODE_INCARNATION_ID_BYTES,
5};
6
7#[cfg(windows)]
8use std::ffi::c_void;
9#[cfg(windows)]
10use std::ptr;
11
12#[derive(Clone, Copy)]
13pub enum AuthDirection {
14 Server,
15 Client,
16}
17
18pub fn random_nonce() -> Result<[u8; NODE_AUTH_NONCE_BYTES], String> {
19 let mut nonce = [0; NODE_AUTH_NONCE_BYTES];
20 fill_random(&mut nonce)?;
21 Ok(nonce)
22}
23
24pub fn random_incarnation_id() -> Result<NodeIncarnationId, String> {
25 let mut bytes = [0; NODE_INCARNATION_ID_BYTES];
26 fill_random(&mut bytes)?;
27 Ok(NodeIncarnationId::from_bytes(bytes))
28}
29
30#[cfg(windows)]
31fn fill_random(bytes: &mut [u8]) -> Result<(), String> {
32 let status = unsafe {
33 BCryptGenRandom(
34 ptr::null_mut(),
35 bytes.as_mut_ptr(),
36 bytes.len() as u32,
37 BCRYPT_USE_SYSTEM_PREFERRED_RNG,
38 )
39 };
40 cng_status("BCryptGenRandom", status)?;
41 Ok(())
42}
43
44#[cfg(unix)]
45fn fill_random(bytes: &mut [u8]) -> Result<(), String> {
46 use ring::rand::SecureRandom;
47
48 ring::rand::SystemRandom::new()
49 .fill(bytes)
50 .map_err(|_| "ring SystemRandom failed".to_owned())
51}
52
53pub fn auth_proof(
54 access_token: &[u8],
55 direction: AuthDirection,
56 role: ClientRole,
57 client_nonce: &[u8; NODE_AUTH_NONCE_BYTES],
58 server_nonce: &[u8; NODE_AUTH_NONCE_BYTES],
59) -> Result<[u8; NODE_AUTH_PROOF_BYTES], String> {
60 let mut message = Vec::with_capacity(32 + (NODE_AUTH_NONCE_BYTES * 2) + BUILD_STAMP.len());
61 message.extend_from_slice(b"gate4agent-node-auth-v3\0");
62 message.extend_from_slice(&(BUILD_STAMP.len() as u16).to_le_bytes());
63 message.extend_from_slice(BUILD_STAMP.as_bytes());
64 message.push(match direction {
65 AuthDirection::Server => 1,
66 AuthDirection::Client => 2,
67 });
68 message.push(match role {
69 ClientRole::Operator => 1,
70 ClientRole::Observer => 2,
71 });
72 message.extend_from_slice(client_nonce);
73 message.extend_from_slice(server_nonce);
74 local_hmac_sha256(access_token, &message)
75}
76
77pub fn negotiated_auth_proof(
78 access_token: &[u8],
79 direction: AuthDirection,
80 role: ClientRole,
81 client_nonce: &[u8; NODE_AUTH_NONCE_BYTES],
82 server_nonce: &[u8; NODE_AUTH_NONCE_BYTES],
83 offer: &ClientCompatibilityOffer,
84 selected: &NegotiatedNodeCompatibility,
85) -> Result<[u8; NODE_AUTH_PROOF_BYTES], String> {
86 let binding = encode_node_compatibility_auth_binding(offer, selected)
87 .map_err(|error| error.to_string())?;
88 let binding_length = u32::try_from(binding.len())
89 .map_err(|_| "node compatibility authentication binding is too large".to_owned())?;
90 let mut message = Vec::with_capacity(
91 48 + (NODE_AUTH_NONCE_BYTES * 2) + BUILD_STAMP.len() + binding.len(),
92 );
93 message.extend_from_slice(b"gate4agent-node-auth-negotiated-v1\0");
94 message.extend_from_slice(&(BUILD_STAMP.len() as u16).to_le_bytes());
95 message.extend_from_slice(BUILD_STAMP.as_bytes());
96 message.push(match direction {
97 AuthDirection::Server => 1,
98 AuthDirection::Client => 2,
99 });
100 message.push(match role {
101 ClientRole::Operator => 1,
102 ClientRole::Observer => 2,
103 });
104 message.extend_from_slice(client_nonce);
105 message.extend_from_slice(server_nonce);
106 message.extend_from_slice(&binding_length.to_le_bytes());
107 message.extend_from_slice(&binding);
108 local_hmac_sha256(access_token, &message)
109}
110
111#[cfg(windows)]
112pub fn local_hmac_sha256(
113 secret: &[u8],
114 message: &[u8],
115) -> Result<[u8; NODE_AUTH_PROOF_BYTES], String> {
116 let mut algorithm = ptr::null_mut();
117 cng_status(
118 "BCryptOpenAlgorithmProvider",
119 unsafe {
120 BCryptOpenAlgorithmProvider(
121 &mut algorithm,
122 BCRYPT_SHA256_ALGORITHM.as_ptr(),
123 ptr::null(),
124 BCRYPT_ALG_HANDLE_HMAC_FLAG,
125 )
126 },
127 )?;
128 let algorithm = AlgorithmHandle(algorithm);
129
130 let mut object_length = 0_u32;
131 let mut copied = 0_u32;
132 cng_status(
133 "BCryptGetProperty(ObjectLength)",
134 unsafe {
135 BCryptGetProperty(
136 algorithm.0,
137 BCRYPT_OBJECT_LENGTH.as_ptr(),
138 (&mut object_length as *mut u32).cast::<u8>(),
139 std::mem::size_of::<u32>() as u32,
140 &mut copied,
141 0,
142 )
143 },
144 )?;
145 if copied != std::mem::size_of::<u32>() as u32 || object_length == 0 {
146 return Err("BCryptGetProperty(ObjectLength) returned an invalid length".to_owned());
147 }
148 let mut object = vec![0_u8; object_length as usize];
149 let mut hash = ptr::null_mut();
150 cng_status(
151 "BCryptCreateHash",
152 unsafe {
153 BCryptCreateHash(
154 algorithm.0,
155 &mut hash,
156 object.as_mut_ptr(),
157 object.len() as u32,
158 secret.as_ptr().cast_mut(),
159 secret.len() as u32,
160 0,
161 )
162 },
163 )?;
164 let hash = HashHandle(hash);
165 cng_status(
166 "BCryptHashData",
167 unsafe {
168 BCryptHashData(
169 hash.0,
170 message.as_ptr().cast_mut(),
171 message.len() as u32,
172 0,
173 )
174 },
175 )?;
176 let mut proof = [0_u8; NODE_AUTH_PROOF_BYTES];
177 cng_status(
178 "BCryptFinishHash",
179 unsafe { BCryptFinishHash(hash.0, proof.as_mut_ptr(), proof.len() as u32, 0) },
180 )?;
181 Ok(proof)
182}
183
184#[cfg(unix)]
185pub fn local_hmac_sha256(
186 secret: &[u8],
187 message: &[u8],
188) -> Result<[u8; NODE_AUTH_PROOF_BYTES], String> {
189 let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, secret);
190 ring::hmac::sign(&key, message)
191 .as_ref()
192 .try_into()
193 .map_err(|_| "ring HMAC-SHA256 returned an invalid proof length".to_owned())
194}
195
196pub fn proofs_match(
197 actual: &[u8; NODE_AUTH_PROOF_BYTES],
198 expected: &[u8; NODE_AUTH_PROOF_BYTES],
199) -> bool {
200 actual
201 .iter()
202 .zip(expected.iter())
203 .fold(0_u8, |difference, (left, right)| difference | (left ^ right))
204 == 0
205}
206
207#[cfg(windows)]
208fn cng_status(operation: &str, status: i32) -> Result<(), String> {
209 if status >= 0 {
210 Ok(())
211 } else {
212 Err(format!(
213 "{operation} failed with NTSTATUS 0x{:08x}",
214 status as u32,
215 ))
216 }
217}
218
219#[cfg(windows)]
220struct AlgorithmHandle(*mut c_void);
221
222#[cfg(windows)]
223impl Drop for AlgorithmHandle {
224 fn drop(&mut self) {
225 unsafe {
226 BCryptCloseAlgorithmProvider(self.0, 0);
227 }
228 }
229}
230
231#[cfg(windows)]
232struct HashHandle(*mut c_void);
233
234#[cfg(windows)]
235impl Drop for HashHandle {
236 fn drop(&mut self) {
237 unsafe {
238 BCryptDestroyHash(self.0);
239 }
240 }
241}
242
243#[cfg(windows)]
244const BCRYPT_USE_SYSTEM_PREFERRED_RNG: u32 = 0x0000_0002;
245#[cfg(windows)]
246const BCRYPT_ALG_HANDLE_HMAC_FLAG: u32 = 0x0000_0008;
247#[cfg(windows)]
248const BCRYPT_SHA256_ALGORITHM: [u16; 7] = [83, 72, 65, 50, 53, 54, 0];
249#[cfg(windows)]
250const BCRYPT_OBJECT_LENGTH: [u16; 13] = [79, 98, 106, 101, 99, 116, 76, 101, 110, 103, 116, 104, 0];
251
252#[cfg(windows)]
253#[link(name = "bcrypt")]
254extern "system" {
255 fn BCryptGenRandom(
256 algorithm: *mut c_void,
257 buffer: *mut u8,
258 buffer_length: u32,
259 flags: u32,
260 ) -> i32;
261 fn BCryptOpenAlgorithmProvider(
262 algorithm: *mut *mut c_void,
263 algorithm_id: *const u16,
264 implementation: *const u16,
265 flags: u32,
266 ) -> i32;
267 fn BCryptCloseAlgorithmProvider(algorithm: *mut c_void, flags: u32) -> i32;
268 fn BCryptGetProperty(
269 object: *mut c_void,
270 property: *const u16,
271 output: *mut u8,
272 output_length: u32,
273 result_length: *mut u32,
274 flags: u32,
275 ) -> i32;
276 fn BCryptCreateHash(
277 algorithm: *mut c_void,
278 hash: *mut *mut c_void,
279 hash_object: *mut u8,
280 hash_object_length: u32,
281 secret: *mut u8,
282 secret_length: u32,
283 flags: u32,
284 ) -> i32;
285 fn BCryptHashData(hash: *mut c_void, input: *mut u8, input_length: u32, flags: u32) -> i32;
286 fn BCryptFinishHash(hash: *mut c_void, output: *mut u8, output_length: u32, flags: u32) -> i32;
287 fn BCryptDestroyHash(hash: *mut c_void) -> i32;
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use gate4agent_node_protocol::{
294 ArchitectureId, CapabilityId, HostDescriptor, LocalTransportKind,
295 NodeCompatibilitySupport, OperatingSystemId, PathEncoding, PathSemantics,
296 PathStyle, ProtocolRange, StateSchemaSupport,
297 NODE_COMPATIBILITY_METADATA_CAPABILITY, NODE_PROVIDER_ID_OPEN_CAPABILITY,
298 };
299
300 fn negotiated_fixture() -> (ClientCompatibilityOffer, NegotiatedNodeCompatibility) {
301 let offer = ClientCompatibilityOffer {
302 build_stamp: BUILD_STAMP.to_owned(),
303 capabilities: vec![CapabilityId::new(
304 NODE_COMPATIBILITY_METADATA_CAPABILITY,
305 )
306 .unwrap()],
307 state_schema: Some(StateSchemaSupport {
308 versions: ProtocolRange::exact(1).unwrap(),
309 }),
310 };
311 let support = NodeCompatibilitySupport {
312 build_stamp: BUILD_STAMP.to_owned(),
313 capabilities: offer.capabilities.clone(),
314 host: HostDescriptor {
315 operating_system: OperatingSystemId::new("windows").unwrap(),
316 architecture: ArchitectureId::new("x86_64").unwrap(),
317 },
318 path_semantics: PathSemantics {
319 style: PathStyle::Windows,
320 encoding: PathEncoding::Utf8,
321 },
322 local_transport: LocalTransportKind::WindowsNamedPipe,
323 state_schema: StateSchemaSupport {
324 versions: ProtocolRange::exact(1).unwrap(),
325 },
326 provider_contracts: Vec::new(),
327 provider_adapter_contracts: Vec::new(),
328 };
329 let selected = support.negotiate(&offer).unwrap();
330 (offer, selected)
331 }
332
333 #[test]
334 fn hmac_sha256_matches_the_standard_vector() {
335 let actual = local_hmac_sha256(
336 b"key",
337 b"The quick brown fox jumps over the lazy dog",
338 )
339 .unwrap();
340 assert_eq!(
341 actual,
342 [
343 0xf7, 0xbc, 0x83, 0xf4, 0x30, 0x53, 0x84, 0x24,
344 0xb1, 0x32, 0x98, 0xe6, 0xaa, 0x6f, 0xb1, 0x43,
345 0xef, 0x4d, 0x59, 0xa1, 0x49, 0x46, 0x17, 0x59,
346 0x97, 0x47, 0x9d, 0xbc, 0x2d, 0x1a, 0x3c, 0xd8,
347 ],
348 );
349 }
350
351 #[test]
367 fn the_legacy_auth_proof_is_an_hmac_over_exactly_this_message() {
368 let client_nonce = [3; NODE_AUTH_NONCE_BYTES];
369 let server_nonce = [7; NODE_AUTH_NONCE_BYTES];
370 let mut expected_message = Vec::new();
371 expected_message.extend_from_slice(b"gate4agent-node-auth-v3\0");
372 expected_message.extend_from_slice(&(BUILD_STAMP.len() as u16).to_le_bytes());
373 expected_message.extend_from_slice(BUILD_STAMP.as_bytes());
374 expected_message.push(1); expected_message.push(1); expected_message.extend_from_slice(&client_nonce);
377 expected_message.extend_from_slice(&server_nonce);
378
379 assert_eq!(
380 auth_proof(
381 b"local-secret",
382 AuthDirection::Server,
383 ClientRole::Operator,
384 &client_nonce,
385 &server_nonce,
386 )
387 .unwrap(),
388 local_hmac_sha256(b"local-secret", &expected_message).unwrap(),
389 );
390 }
391
392 #[test]
393 fn mutual_auth_proofs_are_direction_and_role_bound() {
394 let client_nonce = [3; NODE_AUTH_NONCE_BYTES];
395 let server_nonce = [7; NODE_AUTH_NONCE_BYTES];
396 let server = auth_proof(
397 b"local-secret",
398 AuthDirection::Server,
399 ClientRole::Operator,
400 &client_nonce,
401 &server_nonce,
402 )
403 .unwrap();
404 let client = auth_proof(
405 b"local-secret",
406 AuthDirection::Client,
407 ClientRole::Operator,
408 &client_nonce,
409 &server_nonce,
410 )
411 .unwrap();
412 let observer = auth_proof(
413 b"local-secret",
414 AuthDirection::Server,
415 ClientRole::Observer,
416 &client_nonce,
417 &server_nonce,
418 )
419 .unwrap();
420 assert!(!proofs_match(&server, &client));
421 assert!(!proofs_match(&server, &observer));
422 assert!(proofs_match(&server, &server));
423 }
424
425 #[test]
436 fn the_negotiated_auth_proof_is_an_hmac_over_exactly_this_message_and_is_bound() {
437 let (offer, selected) = negotiated_fixture();
438 let client_nonce = [3; NODE_AUTH_NONCE_BYTES];
439 let server_nonce = [7; NODE_AUTH_NONCE_BYTES];
440 let proof = negotiated_auth_proof(
441 b"local-secret",
442 AuthDirection::Server,
443 ClientRole::Operator,
444 &client_nonce,
445 &server_nonce,
446 &offer,
447 &selected,
448 )
449 .unwrap();
450
451 let binding = encode_node_compatibility_auth_binding(&offer, &selected).unwrap();
452 let mut expected_message = Vec::new();
453 expected_message.extend_from_slice(b"gate4agent-node-auth-negotiated-v1\0");
454 expected_message.extend_from_slice(&(BUILD_STAMP.len() as u16).to_le_bytes());
455 expected_message.extend_from_slice(BUILD_STAMP.as_bytes());
456 expected_message.push(1); expected_message.push(1); expected_message.extend_from_slice(&client_nonce);
459 expected_message.extend_from_slice(&server_nonce);
460 expected_message
461 .extend_from_slice(&u32::try_from(binding.len()).unwrap().to_le_bytes());
462 expected_message.extend_from_slice(&binding);
463 assert_eq!(
464 proof,
465 local_hmac_sha256(b"local-secret", &expected_message).unwrap(),
466 );
467
468 assert!(!proofs_match(
472 &proof,
473 &auth_proof(
474 b"local-secret",
475 AuthDirection::Server,
476 ClientRole::Operator,
477 &client_nonce,
478 &server_nonce,
479 )
480 .unwrap(),
481 ));
482
483 let mut tampered_offer = offer.clone();
484 tampered_offer.capabilities.clear();
485 let offer_proof = negotiated_auth_proof(
486 b"local-secret",
487 AuthDirection::Server,
488 ClientRole::Operator,
489 &client_nonce,
490 &server_nonce,
491 &tampered_offer,
492 &selected,
493 )
494 .unwrap();
495 assert!(!proofs_match(&proof, &offer_proof));
496
497 let mut tampered_selection = selected.clone();
498 tampered_selection.state_schema_version = None;
499 let selection_proof = negotiated_auth_proof(
500 b"local-secret",
501 AuthDirection::Server,
502 ClientRole::Operator,
503 &client_nonce,
504 &server_nonce,
505 &offer,
506 &tampered_selection,
507 )
508 .unwrap();
509 assert!(!proofs_match(&proof, &selection_proof));
510 }
511
512 #[test]
513 fn open_provider_capability_is_bound_in_both_offer_and_selection() {
514 let (mut offer, mut selected) = negotiated_fixture();
515 let capability = CapabilityId::new(NODE_PROVIDER_ID_OPEN_CAPABILITY).unwrap();
516 offer.capabilities.push(capability.clone());
517 selected.capabilities.push(capability);
518 let client_nonce = [3; NODE_AUTH_NONCE_BYTES];
519 let server_nonce = [7; NODE_AUTH_NONCE_BYTES];
520 let proof = negotiated_auth_proof(
521 b"local-secret",
522 AuthDirection::Server,
523 ClientRole::Operator,
524 &client_nonce,
525 &server_nonce,
526 &offer,
527 &selected,
528 )
529 .unwrap();
530
531 let mut legacy_offer = offer.clone();
532 legacy_offer.capabilities.retain(|candidate| {
533 candidate.as_str() != NODE_PROVIDER_ID_OPEN_CAPABILITY
534 });
535 let changed_offer = negotiated_auth_proof(
536 b"local-secret",
537 AuthDirection::Server,
538 ClientRole::Operator,
539 &client_nonce,
540 &server_nonce,
541 &legacy_offer,
542 &selected,
543 )
544 .unwrap();
545 assert!(!proofs_match(&proof, &changed_offer));
546
547 let mut legacy_selection = selected.clone();
548 legacy_selection.capabilities.retain(|candidate| {
549 candidate.as_str() != NODE_PROVIDER_ID_OPEN_CAPABILITY
550 });
551 let changed_selection = negotiated_auth_proof(
552 b"local-secret",
553 AuthDirection::Server,
554 ClientRole::Operator,
555 &client_nonce,
556 &server_nonce,
557 &offer,
558 &legacy_selection,
559 )
560 .unwrap();
561 assert!(!proofs_match(&proof, &changed_selection));
562 }
563
564 #[test]
565 fn random_incarnation_id_is_bounded() {
566 let incarnation_id = random_incarnation_id().unwrap();
567 let encoded = incarnation_id.to_string();
568 assert_eq!(encoded.len(), NODE_INCARNATION_ID_BYTES * 2);
569 assert_eq!(encoded.parse::<NodeIncarnationId>().unwrap(), incarnation_id);
570 }
571}