Skip to main content

gate4agent_node_wire/
auth.rs

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    /// Pins the MESSAGE the proof is an HMAC of, field by field, instead of
352    /// pinning the proof itself.
353    ///
354    /// It used to pin the proof, and could not have worked: `auth_proof`
355    /// mixes `BUILD_STAMP` into that message on purpose, so a peer built
356    /// from another tree cannot authenticate. A frozen output is therefore
357    /// only valid for one build, and every rebuild moves the stamp -- red on
358    /// every build, which is exactly how a test stops being read.
359    ///
360    /// What must not drift is the layout: the domain tag, the build stamp
361    /// length prefix and its ASCII bytes, the direction and role bytes, and
362    /// the two nonces in that order. Reorder any of it, drop the stamp, or
363    /// collide the direction/role encoding and this fails; rebuild the tree
364    /// and it still passes, because the expectation is built from the same
365    /// stamp the code uses.
366    #[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); // AuthDirection::Server
375        expected_message.push(1); // ClientRole::Operator
376        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    /// Same correction as its legacy sibling, plus the part that was
426    /// always right: the negotiated proof is bound to the offer AND to the
427    /// selection, so neither can be tampered with between the two ends.
428    ///
429    /// The layout here carries one thing the legacy message does not -- a
430    /// length prefix ahead of the compatibility binding -- and that prefix
431    /// is what stops a crafted offer/selection pair from shifting bytes
432    /// across the boundary into the nonces. Pinning the layout keeps it
433    /// checked; pinning the output never did, because the build stamp
434    /// inside it moves on every rebuild by design.
435    #[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); // AuthDirection::Server
457        expected_message.push(1); // ClientRole::Operator
458        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        // The two domains must not collide: a legacy proof over the same
469        // token, direction, role and nonces is a different value, so a
470        // peer cannot replay one handshake's proof into the other.
471        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}