Skip to main content

liminal_server/
auth_pass.rs

1//! Registry-minted connection passes.
2
3use ed25519_dalek::{Signature, VerifyingKey};
4use std::collections::BTreeSet;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use crate::ServerError;
8use crate::config::PassConfig;
9
10pub const PASS_VERSION_V1: u8 = 1;
11const SIGNATURE_LEN: usize = 64;
12
13/// Most conversations one pass may scope. A pass arrives inside a `Connect`
14/// before admission, so its eight bytes per conversation must fit the
15/// pre-admission bound; a larger declared count is refused before anything
16/// is allocated for it.
17///
18/// The count check in [`WirePassV1::parse`] is reachable from the wire even
19/// under the 64 KiB pre-admission cap: the count is a declared `u32`, so a
20/// small `Connect` can claim any count without carrying the bytes for it.
21/// The cap bounds the frame; this bound keeps the claim from sizing an
22/// allocation, for the wire and for direct parse callers alike.
23pub(crate) const MAX_PASS_CONVERSATIONS: usize =
24    crate::server::connection::pre_admission::PRE_ADMISSION_FRAME_BOUND / 8;
25const _: () = assert!(MAX_PASS_CONVERSATIONS == 8192);
26
27#[derive(Clone, PartialEq, Eq)]
28pub struct PassPrincipal {
29    pub participant: Vec<u8>,
30    pub public_key: [u8; 32],
31    pub conversations: BTreeSet<u64>,
32    pub live: String,
33    pub may_enroll: bool,
34}
35
36#[derive(Clone, PartialEq, Eq)]
37pub struct WirePassV1 {
38    pub participant: Vec<u8>,
39    pub public_key: [u8; 32],
40    pub conversations: Vec<u64>,
41    pub live: String,
42    pub may_enroll: bool,
43    pub issued_at: u64,
44    pub expires_at: u64,
45    pub signature: [u8; SIGNATURE_LEN],
46}
47
48/// A credential field in a `Debug`: its length, never its bytes
49/// (ACCESS-004 R5, the CREDENTIAL-DEBUG rule).
50pub(crate) struct ByteLength(pub(crate) usize);
51
52impl std::fmt::Debug for ByteLength {
53    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        write!(formatter, "{} bytes", self.0)
55    }
56}
57
58impl std::fmt::Debug for PassPrincipal {
59    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        formatter
61            .debug_struct("PassPrincipal")
62            .field("participant", &ByteLength(self.participant.len()))
63            .field("public_key", &ByteLength(self.public_key.len()))
64            .field("conversations", &self.conversations)
65            .field("live", &ByteLength(self.live.len()))
66            .field("may_enroll", &self.may_enroll)
67            .finish()
68    }
69}
70
71impl std::fmt::Debug for WirePassV1 {
72    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        formatter
74            .debug_struct("WirePassV1")
75            .field("participant", &ByteLength(self.participant.len()))
76            .field("public_key", &ByteLength(self.public_key.len()))
77            .field("conversations", &self.conversations)
78            .field("live", &ByteLength(self.live.len()))
79            .field("may_enroll", &self.may_enroll)
80            .field("issued_at", &self.issued_at)
81            .field("expires_at", &self.expires_at)
82            .field("signature", &ByteLength(self.signature.len()))
83            .finish()
84    }
85}
86
87/// Who a connection is, as the one `Connect` verification proved it
88/// (ACCESS-004 R1).
89///
90/// Only [`PassVerifier::verify`] makes one, from a pass whose registry
91/// signature and validity window it has checked; there is no other
92/// constructor, so no second credential scheme can produce one by accident.
93/// ACCESS-001's Lys-pass verifier becomes the second producer, in this module.
94/// A bearer `Connect` names no one and yields none.
95///
96/// No code outside this module can build one (ACCESS-004 R1):
97///
98/// ```compile_fail,E0451
99/// let holder = liminal_server::auth_pass::VerifiedHolder {
100///     participant: b"watcher".to_vec(),
101///     live: "watcher.".to_owned(),
102/// };
103/// drop(holder);
104/// ```
105#[derive(Clone, PartialEq, Eq)]
106pub struct VerifiedHolder {
107    participant: Vec<u8>,
108    live: String,
109}
110
111impl VerifiedHolder {
112    /// The holder's participant bytes, as the pass named them.
113    #[must_use]
114    pub fn participant(&self) -> &[u8] {
115        &self.participant
116    }
117
118    /// The subject prefix the holder's pass grants.
119    #[must_use]
120    pub fn live(&self) -> &str {
121        &self.live
122    }
123
124    /// Whether `identity` names this holder: its UTF-8 bytes equal the
125    /// participant's exactly (ACCESS-004 R2).
126    #[must_use]
127    pub fn is_named(&self, identity: &str) -> bool {
128        identity.as_bytes() == self.participant.as_slice()
129    }
130}
131
132impl std::fmt::Debug for VerifiedHolder {
133    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        formatter
135            .debug_struct("VerifiedHolder")
136            .field("participant", &ByteLength(self.participant.len()))
137            .field("live", &ByteLength(self.live.len()))
138            .finish()
139    }
140}
141
142/// A pass [`PassVerifier::verify`] accepted: the principal it stamps and the
143/// holder it proves.
144pub(crate) struct VerifiedPass {
145    pub(crate) principal: PassPrincipal,
146    pub(crate) holder: VerifiedHolder,
147}
148
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
150pub enum PassCheckError {
151    Malformed,
152    /// The pass declared more conversations than a pre-admission `Connect`
153    /// can carry, eight bytes each.
154    ScopeTooLarge,
155    Signature,
156    Expired,
157}
158impl PassCheckError {
159    #[must_use]
160    pub const fn check_name(self) -> &'static str {
161        match self {
162            Self::Malformed => "malformed",
163            Self::ScopeTooLarge => "scope_too_large",
164            Self::Signature => "signature",
165            Self::Expired => "expired",
166        }
167    }
168}
169
170#[derive(Clone, Debug)]
171pub(crate) struct PassVerifier {
172    key: VerifyingKey,
173    skew: u64,
174    /// `auth.require_verified_worker` (ACCESS-004 R3), carried here because
175    /// the flag needs a verifier: no holder can be proved without one.
176    require_verified_worker: bool,
177}
178impl PassVerifier {
179    /// The verifier `auth` configures, carrying its
180    /// `require_verified_worker`; `None` when `auth` has no `[auth.pass]`.
181    /// The one derivation for every constructor that reads `[auth]`.
182    ///
183    /// # Errors
184    /// Returns [`ServerError::ConfigValidation`] for a malformed key.
185    pub(crate) fn from_auth(auth: &crate::config::AuthConfig) -> Result<Option<Self>, ServerError> {
186        auth.pass
187            .as_ref()
188            .map(|pass| {
189                Self::from_config(pass).map(|verifier| Self {
190                    require_verified_worker: auth.require_verified_worker,
191                    ..verifier
192                })
193            })
194            .transpose()
195    }
196
197    /// This verifier, refusing every worker registration from a connection
198    /// that proved no holder.
199    #[cfg(test)]
200    pub(crate) const fn requiring_verified_worker(self) -> Self {
201        Self {
202            require_verified_worker: true,
203            ..self
204        }
205    }
206
207    /// Whether a worker registration from a connection that proved no holder
208    /// is refused (ACCESS-004 R3).
209    pub(crate) const fn requires_verified_worker(&self) -> bool {
210        self.require_verified_worker
211    }
212
213    /// Builds a verifier from the required operator pass configuration.
214    ///
215    /// # Errors
216    /// Returns [`ServerError::ConfigValidation`] when the configured key is not
217    /// exactly one valid Ed25519 verifying key encoded as 64 hexadecimal digits.
218    pub(crate) fn from_config(config: &PassConfig) -> Result<Self, ServerError> {
219        let key = hex::decode(&config.registry_verifying_key).map_err(|error| {
220            ServerError::ConfigValidation {
221                message: format!(
222                    "auth.pass.registry_verifying_key: expected 64 hexadecimal digits: {error}"
223                ),
224            }
225        })?;
226        Self::new(&key, config.maximum_clock_skew_seconds).map_err(|_| {
227            ServerError::ConfigValidation {
228                message: "auth.pass.registry_verifying_key: expected a valid 32-byte Ed25519 verifying key".to_owned(),
229            }
230        })
231    }
232
233    /// Builds a verifier from operator configuration.
234    ///
235    /// # Errors
236    /// Returns [`PassCheckError::Malformed`] unless the key is exactly 32 bytes.
237    pub(crate) fn new(key: &[u8], skew: u64) -> Result<Self, PassCheckError> {
238        let key: [u8; 32] = key.try_into().map_err(|_| PassCheckError::Malformed)?;
239        let key = VerifyingKey::from_bytes(&key).map_err(|_| PassCheckError::Malformed)?;
240        Ok(Self {
241            key,
242            skew,
243            require_verified_worker: false,
244        })
245    }
246
247    /// Verifies `bytes` as a registry pass, and names its holder.
248    ///
249    /// # Errors
250    /// Returns the precise malformed, signature, or expired check failure.
251    pub(crate) fn verify(&self, bytes: &[u8]) -> Result<VerifiedPass, PassCheckError> {
252        let principal = WirePassV1::verify(bytes, &self.key, self.skew, SystemTime::now())?;
253        let holder = VerifiedHolder {
254            participant: principal.participant.clone(),
255            live: principal.live.clone(),
256        };
257        Ok(VerifiedPass { principal, holder })
258    }
259}
260
261impl WirePassV1 {
262    /// Encodes all signed fields in fixed order using big-endian lengths and integers.
263    ///
264    /// # Errors
265    /// Returns [`PassCheckError::Malformed`] for noncanonical scope or time ordering.
266    pub fn canonical_unsigned_bytes(&self) -> Result<Vec<u8>, PassCheckError> {
267        if self.issued_at > self.expires_at || self.conversations.windows(2).any(|p| p[0] >= p[1]) {
268            return Err(PassCheckError::Malformed);
269        }
270        let mut out = vec![PASS_VERSION_V1];
271        put_bytes(&mut out, &self.participant)?;
272        out.extend_from_slice(&self.public_key);
273        put_len(&mut out, self.conversations.len())?;
274        for id in &self.conversations {
275            out.extend_from_slice(&id.to_be_bytes());
276        }
277        put_bytes(&mut out, self.live.as_bytes())?;
278        out.push(u8::from(self.may_enroll));
279        out.extend_from_slice(&self.issued_at.to_be_bytes());
280        out.extend_from_slice(&self.expires_at.to_be_bytes());
281        Ok(out)
282    }
283
284    /// Encodes the complete pass including its signature.
285    ///
286    /// # Errors
287    /// Returns [`PassCheckError::Malformed`] for noncanonical input.
288    pub fn canonical_bytes(&self) -> Result<Vec<u8>, PassCheckError> {
289        let mut out = self.canonical_unsigned_bytes()?;
290        out.extend_from_slice(&self.signature);
291        Ok(out)
292    }
293
294    /// Parses canonical version-1 bytes.
295    ///
296    /// # Errors
297    /// Returns [`PassCheckError::Malformed`] for any format violation.
298    pub fn parse(bytes: &[u8]) -> Result<Self, PassCheckError> {
299        let unsigned_len = bytes
300            .len()
301            .checked_sub(SIGNATURE_LEN)
302            .ok_or(PassCheckError::Malformed)?;
303        let (unsigned, sig) = bytes.split_at(unsigned_len);
304        let mut c = Cursor(unsigned);
305        if c.u8()? != PASS_VERSION_V1 {
306            return Err(PassCheckError::Malformed);
307        }
308        let participant = c.bytes()?.to_vec();
309        let public_key = c.array()?;
310        let count = c.len()?;
311        // The count is the client's own claim, read before the signature is
312        // checked: it is bounded before it sizes any allocation.
313        if count > MAX_PASS_CONVERSATIONS {
314            return Err(PassCheckError::ScopeTooLarge);
315        }
316        let mut conversations = Vec::with_capacity(count);
317        for _ in 0..count {
318            conversations.push(c.u64()?);
319        }
320        let live = std::str::from_utf8(c.bytes()?)
321            .map_err(|_| PassCheckError::Malformed)?
322            .to_owned();
323        let may_enroll = match c.u8()? {
324            0 => false,
325            1 => true,
326            _ => return Err(PassCheckError::Malformed),
327        };
328        let issued_at = c.u64()?;
329        let expires_at = c.u64()?;
330        if !c.0.is_empty()
331            || issued_at > expires_at
332            || conversations.windows(2).any(|p| p[0] >= p[1])
333        {
334            return Err(PassCheckError::Malformed);
335        }
336        Ok(Self {
337            participant,
338            public_key,
339            conversations,
340            live,
341            may_enroll,
342            issued_at,
343            expires_at,
344            signature: sig.try_into().map_err(|_| PassCheckError::Malformed)?,
345        })
346    }
347
348    /// Verifies strict Ed25519 signature and the skew-bounded validity window.
349    ///
350    /// # Errors
351    /// Returns the precise malformed, signature, or expired check failure.
352    pub fn verify(
353        bytes: &[u8],
354        key: &VerifyingKey,
355        skew: u64,
356        now: SystemTime,
357    ) -> Result<PassPrincipal, PassCheckError> {
358        let pass = Self::parse(bytes)?;
359        key.verify_strict(
360            &pass.canonical_unsigned_bytes()?,
361            &Signature::from_bytes(&pass.signature),
362        )
363        .map_err(|_| PassCheckError::Signature)?;
364        let now = now
365            .duration_since(UNIX_EPOCH)
366            .map_err(|_| PassCheckError::Expired)?
367            .as_secs();
368        if now < pass.issued_at.saturating_sub(skew) || now > pass.expires_at.saturating_add(skew) {
369            return Err(PassCheckError::Expired);
370        }
371        Ok(PassPrincipal {
372            participant: pass.participant,
373            public_key: pass.public_key,
374            conversations: pass.conversations.into_iter().collect(),
375            live: pass.live,
376            may_enroll: pass.may_enroll,
377        })
378    }
379}
380
381fn put_len(out: &mut Vec<u8>, len: usize) -> Result<(), PassCheckError> {
382    out.extend_from_slice(
383        &u32::try_from(len)
384            .map_err(|_| PassCheckError::Malformed)?
385            .to_be_bytes(),
386    );
387    Ok(())
388}
389fn put_bytes(out: &mut Vec<u8>, bytes: &[u8]) -> Result<(), PassCheckError> {
390    put_len(out, bytes.len())?;
391    out.extend_from_slice(bytes);
392    Ok(())
393}
394struct Cursor<'a>(&'a [u8]);
395impl<'a> Cursor<'a> {
396    const fn take(&mut self, n: usize) -> Result<&'a [u8], PassCheckError> {
397        if self.0.len() < n {
398            return Err(PassCheckError::Malformed);
399        }
400        let (a, b) = self.0.split_at(n);
401        self.0 = b;
402        Ok(a)
403    }
404    fn u8(&mut self) -> Result<u8, PassCheckError> {
405        Ok(self.take(1)?[0])
406    }
407    fn len(&mut self) -> Result<usize, PassCheckError> {
408        let a: [u8; 4] = self
409            .take(4)?
410            .try_into()
411            .map_err(|_| PassCheckError::Malformed)?;
412        usize::try_from(u32::from_be_bytes(a)).map_err(|_| PassCheckError::Malformed)
413    }
414    fn u64(&mut self) -> Result<u64, PassCheckError> {
415        Ok(u64::from_be_bytes(
416            self.take(8)?
417                .try_into()
418                .map_err(|_| PassCheckError::Malformed)?,
419        ))
420    }
421    fn bytes(&mut self) -> Result<&'a [u8], PassCheckError> {
422        let n = self.len()?;
423        self.take(n)
424    }
425    fn array<const N: usize>(&mut self) -> Result<[u8; N], PassCheckError> {
426        self.take(N)?
427            .try_into()
428            .map_err(|_| PassCheckError::Malformed)
429    }
430}
431
432#[cfg(test)]
433mod debug_tests;
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    /// Unsigned-pass bytes up to and including a declared conversation count,
440    /// with no conversations after it, then a zero signature.
441    fn pass_declaring(count: u32) -> Vec<u8> {
442        let mut bytes = vec![PASS_VERSION_V1];
443        bytes.extend_from_slice(&0_u32.to_be_bytes());
444        bytes.extend_from_slice(&[0_u8; 32]);
445        bytes.extend_from_slice(&count.to_be_bytes());
446        bytes.extend_from_slice(&[0_u8; SIGNATURE_LEN]);
447        bytes
448    }
449
450    #[test]
451    fn a_pass_declaring_u32_max_conversations_is_refused_by_name_before_allocation() {
452        assert_eq!(
453            WirePassV1::parse(&pass_declaring(u32::MAX)),
454            Err(PassCheckError::ScopeTooLarge)
455        );
456        assert_eq!(
457            PassCheckError::ScopeTooLarge.check_name(),
458            "scope_too_large"
459        );
460    }
461
462    #[test]
463    fn a_pass_declaring_one_past_the_bound_is_refused_and_the_bound_itself_is_not()
464    -> Result<(), Box<dyn std::error::Error>> {
465        let at_bound = u32::try_from(MAX_PASS_CONVERSATIONS)?;
466        assert_eq!(
467            WirePassV1::parse(&pass_declaring(at_bound + 1)),
468            Err(PassCheckError::ScopeTooLarge)
469        );
470        // At the bound the count is admitted; the missing conversations then
471        // make the pass malformed.
472        assert_eq!(
473            WirePassV1::parse(&pass_declaring(at_bound)),
474            Err(PassCheckError::Malformed)
475        );
476        Ok(())
477    }
478
479    #[test]
480    fn shared_version_one_vector_is_byte_exact_and_verifies()
481    -> Result<(), Box<dyn std::error::Error>> {
482        let vector: serde_json::Value =
483            serde_json::from_str(include_str!("../test-vectors/wire-pass-v1.json"))?;
484        let unsigned = hex::decode(
485            vector["unsigned_pass_hex"]
486                .as_str()
487                .ok_or("unsigned bytes")?,
488        )?;
489        let signature: [u8; 64] =
490            hex::decode(vector["signature_hex"].as_str().ok_or("signature")?)?
491                .try_into()
492                .map_err(|_| "signature length")?;
493        let bytes = hex::decode(vector["pass_hex"].as_str().ok_or("pass bytes")?)?;
494        let registry_key: [u8; 32] = hex::decode(
495            vector["registry_verifying_key_hex"]
496                .as_str()
497                .ok_or("verifying key")?,
498        )?
499        .try_into()
500        .map_err(|_| "key length")?;
501        let pass = WirePassV1::parse(&bytes).map_err(|error| format!("parse failed: {error:?}"))?;
502        assert_eq!(
503            pass.canonical_unsigned_bytes()
504                .map_err(|error| format!("encode failed: {error:?}"))?,
505            unsigned
506        );
507        assert_eq!(pass.signature, signature);
508        let key = VerifyingKey::from_bytes(&registry_key)?;
509        let principal = WirePassV1::verify(
510            &bytes,
511            &key,
512            0,
513            UNIX_EPOCH + std::time::Duration::from_secs(pass.issued_at),
514        )
515        .map_err(|error| format!("verify failed: {error:?}"))?;
516        assert_eq!(principal.participant, b"participant-42");
517        assert_eq!(
518            principal.conversations.into_iter().collect::<Vec<_>>(),
519            vec![7, 42, 9001]
520        );
521        assert_eq!(principal.live, "workspace/acme/");
522        assert!(principal.may_enroll);
523        Ok(())
524    }
525}