Skip to main content

zerodds_security_pki/
auth_request.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! `AuthRequestMessageToken` — DDS-Security 1.2 §9.3.2.5.1.1.
5//!
6//! Pre-Handshake-Token, vom Initiator via Builtin-Topic
7//! `ParticipantStatelessMessage` an einen unbekannten Remote-
8//! Participant geschickt, um die `handshake_request_message`-Sequenz
9//! anzustossen.
10//!
11//! ```text
12//!   class_id  = "DDS:Auth:PKI-DH:1.0+AuthReq"
13//!   properties = {}
14//!   binary_properties = { future_challenge = <256-bit nonce> }
15//! ```
16//!
17//! Spec §9.3.2.5.1.1: das `future_challenge` muss im nachfolgenden
18//! `HandshakeRequest`-Token als `challenge1` echoed werden, um Replay-
19//! Attacks zu verhindern.
20
21use alloc::format;
22use alloc::string::{String, ToString};
23use alloc::vec::Vec;
24
25use crate::identity::PkiError;
26
27/// Class-ID laut Spec §9.3.2.5.1.1.
28pub const AUTH_REQUEST_CLASS_ID: &str = "DDS:Auth:PKI-DH:1.0+AuthReq";
29
30/// Property-Key fuer den Future-Challenge-Wert.
31pub const FUTURE_CHALLENGE_KEY: &str = "future_challenge";
32
33/// `AuthRequestMessageToken`.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct AuthRequestToken {
36    /// 32-Byte (256-bit) Nonce, der im naechsten HandshakeRequest
37    /// als `challenge1` wieder auftauchen muss.
38    pub future_challenge: [u8; 32],
39}
40
41impl AuthRequestToken {
42    /// Konstruktor.
43    #[must_use]
44    pub fn new(challenge: [u8; 32]) -> Self {
45        Self {
46            future_challenge: challenge,
47        }
48    }
49
50    /// Encode zu Wire-Bytes (TLV: 1-Byte-Class-ID-Length + Class-ID +
51    /// 1-Byte-Key-Length + Key + 2-Byte-BE-Value-Length + Value).
52    /// Caller-Layer mappt das in den Builtin-Topic-Wire-Format.
53    #[must_use]
54    pub fn encode(&self) -> Vec<u8> {
55        let class_id = AUTH_REQUEST_CLASS_ID.as_bytes();
56        let key = FUTURE_CHALLENGE_KEY.as_bytes();
57        let mut out = Vec::with_capacity(1 + class_id.len() + 1 + key.len() + 2 + 32);
58        out.push(class_id.len() as u8);
59        out.extend_from_slice(class_id);
60        out.push(key.len() as u8);
61        out.extend_from_slice(key);
62        out.extend_from_slice(&(32u16.to_be_bytes()));
63        out.extend_from_slice(&self.future_challenge);
64        out
65    }
66
67    /// Decode Wire-Bytes.
68    ///
69    /// # Errors
70    /// `PkiError::InvalidPem` (re-used als Generic-Decode-Error) wenn:
71    /// * Class-ID falsch
72    /// * Property-Key falsch
73    /// * Value-Length != 32
74    /// * Buffer truncated
75    pub fn decode(bytes: &[u8]) -> Result<Self, PkiError> {
76        let mut pos = 0usize;
77        if bytes.len() <= pos {
78            return Err(PkiError::InvalidPem("AuthReq truncated at class-id".into()));
79        }
80        let cid_len = bytes[pos] as usize;
81        pos += 1;
82        if bytes.len() < pos + cid_len {
83            return Err(PkiError::InvalidPem("AuthReq class-id truncated".into()));
84        }
85        let cid = core::str::from_utf8(&bytes[pos..pos + cid_len])
86            .map_err(|_| PkiError::InvalidPem("AuthReq class-id non-utf8".into()))?;
87        if cid != AUTH_REQUEST_CLASS_ID {
88            return Err(PkiError::InvalidPem(format!(
89                "AuthReq class-id mismatch: got `{cid}`"
90            )));
91        }
92        pos += cid_len;
93        if bytes.len() <= pos {
94            return Err(PkiError::InvalidPem(
95                "AuthReq truncated at key-length".into(),
96            ));
97        }
98        let key_len = bytes[pos] as usize;
99        pos += 1;
100        if bytes.len() < pos + key_len {
101            return Err(PkiError::InvalidPem("AuthReq key truncated".into()));
102        }
103        let key = core::str::from_utf8(&bytes[pos..pos + key_len])
104            .map_err(|_| PkiError::InvalidPem("AuthReq key non-utf8".into()))?;
105        if key != FUTURE_CHALLENGE_KEY {
106            return Err(PkiError::InvalidPem(format!(
107                "AuthReq key mismatch: got `{key}`"
108            )));
109        }
110        pos += key_len;
111        if bytes.len() < pos + 2 {
112            return Err(PkiError::InvalidPem(
113                "AuthReq value-length truncated".into(),
114            ));
115        }
116        let val_len = u16::from_be_bytes([bytes[pos], bytes[pos + 1]]) as usize;
117        pos += 2;
118        if val_len != 32 {
119            return Err(PkiError::InvalidPem(format!(
120                "AuthReq future_challenge must be 32 bytes, got {val_len}"
121            )));
122        }
123        if bytes.len() < pos + val_len {
124            return Err(PkiError::InvalidPem("AuthReq value truncated".into()));
125        }
126        let mut challenge = [0u8; 32];
127        challenge.copy_from_slice(&bytes[pos..pos + 32]);
128        Ok(Self {
129            future_challenge: challenge,
130        })
131    }
132}
133
134/// Helper: Property-Liste-Format fuer das DDS-Security-Plugin-API
135/// (Caller-Layer baut daraus den Builtin-Topic-Sample).
136#[must_use]
137pub fn auth_request_properties(token: &AuthRequestToken) -> Vec<(String, Vec<u8>)> {
138    alloc::vec![(
139        FUTURE_CHALLENGE_KEY.to_string(),
140        token.future_challenge.to_vec(),
141    )]
142}
143
144#[cfg(test)]
145#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn round_trip_preserves_challenge() {
151        let chal = [0x42u8; 32];
152        let token = AuthRequestToken::new(chal);
153        let bytes = token.encode();
154        let back = AuthRequestToken::decode(&bytes).unwrap();
155        assert_eq!(back, token);
156    }
157
158    #[test]
159    fn class_id_must_match() {
160        let mut bytes = AuthRequestToken::new([1u8; 32]).encode();
161        // Corrupt class-id by flipping a byte.
162        bytes[5] ^= 0xff;
163        let err = AuthRequestToken::decode(&bytes).unwrap_err();
164        assert!(matches!(err, PkiError::InvalidPem(_)));
165    }
166
167    #[test]
168    fn truncated_buffer_rejected() {
169        let bytes = AuthRequestToken::new([1u8; 32]).encode();
170        for cut in 1..bytes.len() {
171            assert!(
172                AuthRequestToken::decode(&bytes[..cut]).is_err(),
173                "buffer truncated at {cut} should fail"
174            );
175        }
176    }
177
178    #[test]
179    fn wrong_value_length_rejected() {
180        let mut bytes = AuthRequestToken::new([1u8; 32]).encode();
181        // Replace value-length with 16 instead of 32.
182        let val_len_pos = 1 + AUTH_REQUEST_CLASS_ID.len() + 1 + FUTURE_CHALLENGE_KEY.len();
183        bytes[val_len_pos] = 0;
184        bytes[val_len_pos + 1] = 16;
185        let err = AuthRequestToken::decode(&bytes).unwrap_err();
186        assert!(matches!(err, PkiError::InvalidPem(_)));
187    }
188
189    #[test]
190    fn properties_helper_returns_kv_list() {
191        let token = AuthRequestToken::new([7u8; 32]);
192        let props = auth_request_properties(&token);
193        assert_eq!(props.len(), 1);
194        assert_eq!(props[0].0, FUTURE_CHALLENGE_KEY);
195        assert_eq!(props[0].1.len(), 32);
196    }
197
198    #[test]
199    fn class_id_constant_matches_spec() {
200        // Spec §9.3.2.5.1.1 — verbatim from the standard text.
201        assert_eq!(AUTH_REQUEST_CLASS_ID, "DDS:Auth:PKI-DH:1.0+AuthReq");
202    }
203
204    #[test]
205    fn empty_input_rejected() {
206        assert!(AuthRequestToken::decode(&[]).is_err());
207    }
208
209    #[test]
210    fn class_id_length_byte_too_large_rejected() {
211        let bytes = alloc::vec![0xff, b'x'];
212        assert!(AuthRequestToken::decode(&bytes).is_err());
213    }
214}