entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! LDAP v3 message grammar (RFC 4511 §4): decode the request ops a read-only
//! server needs (bind / search / unbind) and encode the responses (bind
//! response, search result entry, search result done).
//!
//! `LDAPMessage ::= SEQUENCE { messageID INTEGER, protocolOp CHOICE, controls
//! [0] OPTIONAL }` — controls are parsed-past (ignored).
#![allow(clippy::doc_markdown)]

use super::ber::{
    BerError, Children, encode_enumerated, encode_octet_string, encode_sequence, parse_bool,
    parse_integer, parse_tlv, tlv,
};
use super::filter::{Filter, decode_filter};

// protocolOp application tags.
const TAG_BIND_REQUEST: u8 = 0x60;
const TAG_BIND_RESPONSE: u8 = 0x61;
const TAG_UNBIND_REQUEST: u8 = 0x42;
const TAG_SEARCH_REQUEST: u8 = 0x63;
const TAG_SEARCH_RESULT_ENTRY: u8 = 0x64;
const TAG_SEARCH_RESULT_DONE: u8 = 0x65;
/// simple authentication [0] within a BindRequest.
const TAG_SIMPLE_AUTH: u8 = 0x80;

/// LDAP result codes (RFC 4511 §4.1.9), the ones this server emits.
pub mod result_code {
    /// Operation succeeded.
    pub const SUCCESS: i64 = 0;
    /// Malformed / unparseable request.
    pub const PROTOCOL_ERROR: i64 = 2;
    /// The result set was truncated to the client's requested `sizeLimit`.
    pub const SIZE_LIMIT_EXCEEDED: i64 = 4;
    /// The named base object does not exist (RFC 4511 §4.1.9 `noSuchObject`).
    pub const NO_SUCH_OBJECT: i64 = 32;
    /// Bind failed — bad credentials.
    pub const INVALID_CREDENTIALS: i64 = 49;
    /// The bound identity lacks rights for the operation.
    pub const INSUFFICIENT_ACCESS: i64 = 50;
    /// The server declines to perform the operation.
    pub const UNWILLING_TO_PERFORM: i64 = 53;
}

/// Search scope (RFC 4511 §4.5.1.2).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
    /// The base object only.
    Base,
    /// Immediate children of the base object.
    OneLevel,
    /// The base object and its whole subtree.
    Subtree,
}

/// A decoded request.
#[derive(Debug, Clone)]
pub enum LdapOp {
    /// A bind (authenticate) request.
    Bind(BindRequest),
    /// A search request.
    Search(SearchRequest),
    /// An unbind (disconnect) request.
    Unbind,
    /// A protocol op this read path does not handle (carries its tag).
    Unsupported(u8),
}

/// A BIND request (simple auth only; SASL leaves `simple_password` `None`).
#[derive(Debug, Clone)]
pub struct BindRequest {
    /// LDAP protocol version (3 for LDAP v3).
    pub version: i64,
    /// The bind DN (the identity authenticating).
    pub name: String,
    /// The simple-auth password, or `None` for SASL / anonymous binds.
    pub simple_password: Option<String>,
}

/// A SEARCH request (the read path uses base + scope + filter + attributes).
#[derive(Debug, Clone)]
pub struct SearchRequest {
    /// The base object DN to search under.
    pub base: String,
    /// The search scope.
    pub scope: Scope,
    /// The client's requested maximum entry count (`0` = no client-side limit).
    pub size_limit: i64,
    /// The search filter.
    pub filter: Filter,
    /// The requested attributes (empty = all user attributes).
    pub attributes: Vec<String>,
    /// `typesOnly`: return attribute descriptions without their values
    /// (RFC 4511 §4.5.1.6).
    pub types_only: bool,
}

/// A decoded LDAP message.
#[derive(Debug, Clone)]
pub struct LdapMessage {
    /// The client-chosen message id (echoed in the response).
    pub message_id: i64,
    /// The protocol operation.
    pub op: LdapOp,
}

impl LdapMessage {
    /// Parse one message from the front of `buf`, returning it plus the number
    /// of bytes consumed (so a stream reader can frame the next message).
    ///
    /// # Errors
    ///
    /// [`BerError`] on a malformed message. A truncated buffer (need more
    /// bytes) surfaces as `BerError("truncated content")` — the caller reads
    /// more and retries.
    pub fn parse(buf: &[u8]) -> Result<(Self, usize), BerError> {
        // Only the OUTER TLV can be genuinely "incomplete" (need more bytes).
        let (tag, content, rest) = parse_tlv(buf)?;
        let consumed = buf.len() - rest.len();
        // The outer frame is fully present, so any error decoding its body is a
        // complete-but-malformed PDU — reclassify it so is_incomplete() stays
        // false and a stream framer rejects it instead of buffering forever
        // waiting for bytes that can't change the outcome.
        let msg = Self::parse_body(tag, content).map_err(BerError::into_inner_malformed)?;
        Ok((msg, consumed))
    }

    fn parse_body(tag: u8, content: &[u8]) -> Result<Self, BerError> {
        if tag != 0x30 {
            return Err(BerError("LDAPMessage is not a SEQUENCE"));
        }
        let mut children = Children::new(content);
        let (id_tag, id_content) = children.next().ok_or(BerError("no messageID"))??;
        if id_tag != 0x02 {
            return Err(BerError("messageID not INTEGER"));
        }
        let message_id = parse_integer(id_content)?;

        let (op_tag, op_content) = children.next().ok_or(BerError("no protocolOp"))??;
        let op = match op_tag {
            TAG_BIND_REQUEST => LdapOp::Bind(parse_bind(op_content)?),
            TAG_SEARCH_REQUEST => LdapOp::Search(parse_search(op_content)?),
            TAG_UNBIND_REQUEST => LdapOp::Unbind,
            other => LdapOp::Unsupported(other),
        };
        Ok(LdapMessage { message_id, op })
    }
}

fn parse_bind(content: &[u8]) -> Result<BindRequest, BerError> {
    let mut c = Children::new(content);
    let (_vt, vc) = c.next().ok_or(BerError("bind: no version"))??;
    let version = parse_integer(vc)?;
    let (_nt, nc) = c.next().ok_or(BerError("bind: no name"))??;
    let name = String::from_utf8_lossy(nc).into_owned();
    let simple_password = match c.next() {
        Some(Ok((TAG_SIMPLE_AUTH, pc))) => Some(String::from_utf8_lossy(pc).into_owned()),
        _ => None, // SASL / absent — unsupported here.
    };
    Ok(BindRequest {
        version,
        name,
        simple_password,
    })
}

fn parse_search(content: &[u8]) -> Result<SearchRequest, BerError> {
    let mut c = Children::new(content);
    let (_bt, bc) = c.next().ok_or(BerError("search: no base"))??;
    let base = String::from_utf8_lossy(bc).into_owned();
    let (_st, sc) = c.next().ok_or(BerError("search: no scope"))??;
    let scope = match parse_integer(sc)? {
        0 => Scope::Base,
        1 => Scope::OneLevel,
        _ => Scope::Subtree,
    };
    // derefAliases parsed-past; sizeLimit captured; timeLimit/typesOnly past.
    let _deref = c.next().ok_or(BerError("search: no deref"))??;
    let (_szt, szc) = c.next().ok_or(BerError("search: no sizeLimit"))??;
    // A malformed INTEGER propagates like every other field rather than
    // silently becoming 0 — 0 means "no client-imposed limit" (RFC 4511
    // §4.5.1.4), so swallowing the error turns an unparseable PDU into an
    // *unbounded* search. A negative value is likewise a protocol violation,
    // not "no limit".
    let size_limit = parse_integer(szc)?;
    if size_limit < 0 {
        return Err(BerError("search: negative sizeLimit"));
    }
    let _time = c.next().ok_or(BerError("search: no timeLimit"))??;
    let (tt, tc) = c.next().ok_or(BerError("search: no typesOnly"))??;
    let types_only = tt == 0x01 && parse_bool(tc).unwrap_or(false);
    // filter (one TLV of any filter tag).
    let (ft, fc) = c.next().ok_or(BerError("search: no filter"))??;
    let filter = decode_filter(ft, fc)?;
    // attributes SEQUENCE OF OCTET STRING (optional / possibly empty).
    let mut attributes = Vec::new();
    if let Some(Ok((0x30, attrs_content))) = c.next() {
        for child in Children::new(attrs_content) {
            let (_t, ac) = child?;
            attributes.push(String::from_utf8_lossy(ac).into_owned());
        }
    }
    Ok(SearchRequest {
        base,
        scope,
        size_limit,
        filter,
        attributes,
        types_only,
    })
}

// ── Response encoding ───────────────────────────────────────────────────────

/// An LDAPResult (resultCode + matchedDN + diagnosticMessage).
#[derive(Debug, Clone)]
pub struct LdapResult {
    /// The LDAP result code (see [`result_code`]).
    pub code: i64,
    /// The matched DN (empty unless a partial match is reported).
    pub matched_dn: String,
    /// A human-readable diagnostic message (non-secret).
    pub message: String,
}

impl LdapResult {
    /// A success result with empty matchedDN / message.
    #[must_use]
    pub fn success() -> Self {
        Self {
            code: result_code::SUCCESS,
            matched_dn: String::new(),
            message: String::new(),
        }
    }

    /// A failure result with a code + diagnostic message.
    #[must_use]
    pub fn failure(code: i64, message: &str) -> Self {
        Self {
            code,
            matched_dn: String::new(),
            message: message.to_string(),
        }
    }

    fn encode_body(&self) -> Vec<u8> {
        [
            encode_enumerated(self.code),
            encode_octet_string(self.matched_dn.as_bytes()),
            encode_octet_string(self.message.as_bytes()),
        ]
        .concat()
    }
}

/// Wrap a protocol op in an `LDAPMessage` SEQUENCE with the given id.
fn wrap(message_id: i64, op: Vec<u8>) -> Vec<u8> {
    encode_sequence(&[super::ber::encode_integer(message_id), op])
}

/// Encode a `bindResponse` message.
#[must_use]
pub fn encode_bind_response(message_id: i64, result: &LdapResult) -> Vec<u8> {
    wrap(message_id, tlv(TAG_BIND_RESPONSE, &result.encode_body()))
}

/// Encode a `searchResultDone` message.
#[must_use]
pub fn encode_search_result_done(message_id: i64, result: &LdapResult) -> Vec<u8> {
    wrap(
        message_id,
        tlv(TAG_SEARCH_RESULT_DONE, &result.encode_body()),
    )
}

/// Encode a `searchResultEntry` message for a DN + its attributes.
#[must_use]
pub fn encode_search_result_entry(
    message_id: i64,
    dn: &str,
    attributes: &[(String, Vec<String>)],
) -> Vec<u8> {
    let attrs: Vec<Vec<u8>> = attributes
        .iter()
        .map(|(name, values)| {
            let vals: Vec<Vec<u8>> = values
                .iter()
                .map(|v| encode_octet_string(v.as_bytes()))
                .collect();
            // PartialAttribute ::= SEQUENCE { type OCTET, vals SET OF OCTET }.
            let set = tlv(0x31, &vals.concat());
            encode_sequence(&[encode_octet_string(name.as_bytes()), set])
        })
        .collect();
    let body = [encode_octet_string(dn.as_bytes()), encode_sequence(&attrs)].concat();
    wrap(message_id, tlv(TAG_SEARCH_RESULT_ENTRY, &body))
}

#[cfg(test)]
mod tests {
    use super::super::ber::{
        Children, encode_integer, encode_octet_string, encode_sequence, parse_tlv, tlv,
    };
    use super::*;

    #[test]
    fn parses_simple_bind() {
        // messageID=1, bindRequest{ version=3, name="cn=admin", simple="pw" }.
        let bind_body = [
            encode_integer(3),
            encode_octet_string(b"cn=admin"),
            tlv(TAG_SIMPLE_AUTH, b"pw"),
        ]
        .concat();
        let msg = encode_sequence(&[encode_integer(1), tlv(TAG_BIND_REQUEST, &bind_body)]);
        let (parsed, consumed) = LdapMessage::parse(&msg).unwrap();
        assert_eq!(consumed, msg.len());
        assert_eq!(parsed.message_id, 1);
        match parsed.op {
            LdapOp::Bind(b) => {
                assert_eq!(b.version, 3);
                assert_eq!(b.name, "cn=admin");
                assert_eq!(b.simple_password.as_deref(), Some("pw"));
            }
            _ => panic!("expected bind"),
        }
    }

    #[test]
    fn parses_search_with_present_filter() {
        // searchRequest{ base="dc=x", scope=sub, deref=0, size=0, time=0,
        //   typesOnly=false, filter=(objectClass=*), attributes=["cn","mail"] }.
        let attrs = encode_sequence(&[encode_octet_string(b"cn"), encode_octet_string(b"mail")]);
        let body = [
            encode_octet_string(b"dc=x"),
            encode_enumerated(2),
            encode_enumerated(0),
            encode_integer(0),
            encode_integer(0),
            tlv(0x01, &[0x00]),
            tlv(0x87, b"objectClass"), // present filter
            attrs,
        ]
        .concat();
        let msg = encode_sequence(&[encode_integer(2), tlv(TAG_SEARCH_REQUEST, &body)]);
        let (parsed, _) = LdapMessage::parse(&msg).unwrap();
        assert_eq!(parsed.message_id, 2);
        match parsed.op {
            LdapOp::Search(s) => {
                assert_eq!(s.base, "dc=x");
                assert_eq!(s.scope, Scope::Subtree);
                assert_eq!(s.attributes, vec!["cn", "mail"]);
                assert_eq!(s.filter, Filter::Present("objectClass".into()));
            }
            _ => panic!("expected search"),
        }
    }

    #[test]
    fn parses_unbind() {
        let msg = encode_sequence(&[encode_integer(3), tlv(TAG_UNBIND_REQUEST, &[])]);
        let (parsed, _) = LdapMessage::parse(&msg).unwrap();
        assert!(matches!(parsed.op, LdapOp::Unbind));
    }

    #[test]
    fn encodes_and_reparses_result_entry() {
        let entry = encode_search_result_entry(
            5,
            "uid=bob,dc=x",
            &[
                ("cn".into(), vec!["Bob".into()]),
                ("mail".into(), vec!["b@x".into()]),
            ],
        );
        // Structurally: LDAPMessage SEQUENCE { id=5, [APPLICATION 4] {...} }.
        let (tag, content, _) = parse_tlv(&entry).unwrap();
        assert_eq!(tag, 0x30);
        let kids: Vec<_> = Children::new(content).map(Result::unwrap).collect();
        assert_eq!(kids[0].0, 0x02); // messageID
        assert_eq!(kids[1].0, TAG_SEARCH_RESULT_ENTRY);
    }

    #[test]
    fn encodes_bind_response_success() {
        let r = encode_bind_response(1, &LdapResult::success());
        let (_, content, _) = parse_tlv(&r).unwrap();
        let kids: Vec<_> = Children::new(content).map(Result::unwrap).collect();
        assert_eq!(kids[1].0, TAG_BIND_RESPONSE);
        // resultCode is the first child of the response body → ENUMERATED 0.
        let body_kids: Vec<_> = Children::new(kids[1].1).map(Result::unwrap).collect();
        assert_eq!(body_kids[0].0, 0x0a);
        assert_eq!(parse_integer(body_kids[0].1).unwrap(), 0);
    }

    #[test]
    fn complete_frame_with_malformed_body_is_not_incomplete() {
        // Outer SEQUENCE fully present (len=3, 3 content bytes), but the inner
        // messageID tag is 0x05 (NULL) not 0x02 (INTEGER): the frame is
        // complete, so the error must NOT be classified as "incomplete".
        let buf = [0x30, 0x03, 0x05, 0x01, 0x00];
        let err = LdapMessage::parse(&buf).unwrap_err();
        assert!(
            !err.is_incomplete(),
            "complete-but-malformed frame must not read as incomplete: {err:?}"
        );
    }

    #[test]
    fn truncated_outer_frame_is_incomplete() {
        // Outer declares length 10 but only 2 content bytes are present.
        let buf = [0x30, 0x0a, 0x02, 0x01];
        let err = LdapMessage::parse(&buf).unwrap_err();
        assert!(
            err.is_incomplete(),
            "short outer frame must read as incomplete: {err:?}"
        );
    }
}