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/// Two verifications make one, both in this module: [`PassVerifier::verify`],
91/// from a pass whose registry signature and validity window it has checked,
92/// and [`VerifiedHolder::from_lys_session`], from a Lys pass the installed
93/// admitter verified offline at `Connect` (ACCESS-001, the second producer).
94/// There is no other constructor, so no further credential scheme can
95/// produce one by accident. A bearer `Connect` names no one and yields none.
96///
97/// No code outside this module can build one (ACCESS-004 R1):
98///
99/// ```compile_fail,E0451
100/// let holder = liminal_server::auth_pass::VerifiedHolder {
101///     participant: b"watcher".to_vec(),
102///     live: "watcher.".to_owned(),
103/// };
104/// drop(holder);
105/// ```
106#[derive(Clone, PartialEq, Eq)]
107pub struct VerifiedHolder {
108    participant: Vec<u8>,
109    live: String,
110    source: HolderSource,
111}
112
113/// Which verification proved a holder, and so where its subject rights are
114/// decided.
115#[derive(Clone, Debug, PartialEq, Eq)]
116enum HolderSource {
117    /// A registry pass: rights are its live prefix.
118    RegistryPass,
119    /// A Lys pass: rights are decided per subject by the connection's Lys
120    /// session; `kind` is the holder's directory kind as the signed pass
121    /// states it (Person, Agent, `ServiceAccount`, Connector), `None` when
122    /// the pass does not state one.
123    Lys { kind: Option<String> },
124}
125
126impl VerifiedHolder {
127    /// The holder a Lys pass proved, as the admitted session names it
128    /// (ACCESS-004 R1's second producer, ACCESS-001). Called only from the
129    /// `Connect` verification, with the session the admitter returned.
130    pub(crate) fn from_lys_session(session: &dyn crate::auth_lys::session::LysSession) -> Self {
131        Self {
132            participant: session.holder().as_bytes().to_vec(),
133            live: String::new(),
134            source: HolderSource::Lys {
135                kind: session.holder_kind().map(str::to_owned),
136            },
137        }
138    }
139
140    /// The holder's Lys directory kind, when a Lys pass proved it and stated
141    /// one; `None` for a registry-pass holder.
142    #[must_use]
143    pub fn kind(&self) -> Option<&str> {
144        match &self.source {
145            HolderSource::RegistryPass => None,
146            HolderSource::Lys { kind } => kind.as_deref(),
147        }
148    }
149
150    /// Whether this holder's own grant covers `channel`. A registry pass
151    /// grants its live prefix, and an empty prefix grants nothing. A Lys
152    /// holder's rights are the Lys session's per-subject decision, which the
153    /// connection makes before it asks this; the holder adds no second rule.
154    #[must_use]
155    pub fn allows_channel(&self, channel: &str) -> bool {
156        match self.source {
157            HolderSource::RegistryPass => {
158                !self.live.is_empty() && channel.starts_with(self.live.as_str())
159            }
160            HolderSource::Lys { .. } => true,
161        }
162    }
163
164    /// The holder's participant bytes, as the pass named them.
165    #[must_use]
166    pub fn participant(&self) -> &[u8] {
167        &self.participant
168    }
169
170    /// The subject prefix the holder's pass grants.
171    #[must_use]
172    pub fn live(&self) -> &str {
173        &self.live
174    }
175
176    /// Whether `identity` names this holder: its UTF-8 bytes equal the
177    /// participant's exactly (ACCESS-004 R2).
178    #[must_use]
179    pub fn is_named(&self, identity: &str) -> bool {
180        identity.as_bytes() == self.participant.as_slice()
181    }
182}
183
184impl std::fmt::Debug for VerifiedHolder {
185    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        formatter
187            .debug_struct("VerifiedHolder")
188            .field("participant", &ByteLength(self.participant.len()))
189            .field("live", &ByteLength(self.live.len()))
190            .field("source", &self.source)
191            .finish()
192    }
193}
194
195/// A pass [`PassVerifier::verify`] accepted: the principal it stamps and the
196/// holder it proves.
197pub(crate) struct VerifiedPass {
198    pub(crate) principal: PassPrincipal,
199    pub(crate) holder: VerifiedHolder,
200}
201
202#[derive(Clone, Copy, Debug, PartialEq, Eq)]
203pub enum PassCheckError {
204    Malformed,
205    /// The pass declared more conversations than a pre-admission `Connect`
206    /// can carry, eight bytes each.
207    ScopeTooLarge,
208    Signature,
209    Expired,
210}
211impl PassCheckError {
212    #[must_use]
213    pub const fn check_name(self) -> &'static str {
214        match self {
215            Self::Malformed => "malformed",
216            Self::ScopeTooLarge => "scope_too_large",
217            Self::Signature => "signature",
218            Self::Expired => "expired",
219        }
220    }
221}
222
223#[derive(Clone, Debug)]
224pub(crate) struct PassVerifier {
225    key: VerifyingKey,
226    skew: u64,
227    /// `auth.require_verified_worker` (ACCESS-004 R3), carried here because
228    /// the flag needs a verifier: no holder can be proved without one.
229    require_verified_worker: bool,
230}
231impl PassVerifier {
232    /// The verifier `auth` configures, carrying its
233    /// `require_verified_worker`; `None` when `auth` has no `[auth.pass]`.
234    /// The one derivation for every constructor that reads `[auth]`.
235    ///
236    /// # Errors
237    /// Returns [`ServerError::ConfigValidation`] for a malformed key.
238    pub(crate) fn from_auth(auth: &crate::config::AuthConfig) -> Result<Option<Self>, ServerError> {
239        auth.pass
240            .as_ref()
241            .map(|pass| {
242                Self::from_config(pass).map(|verifier| Self {
243                    require_verified_worker: auth.require_verified_worker,
244                    ..verifier
245                })
246            })
247            .transpose()
248    }
249
250    /// This verifier, refusing every worker registration from a connection
251    /// that proved no holder.
252    #[cfg(test)]
253    pub(crate) const fn requiring_verified_worker(self) -> Self {
254        Self {
255            require_verified_worker: true,
256            ..self
257        }
258    }
259
260    /// Whether a worker registration from a connection that proved no holder
261    /// is refused (ACCESS-004 R3).
262    pub(crate) const fn requires_verified_worker(&self) -> bool {
263        self.require_verified_worker
264    }
265
266    /// Builds a verifier from the required operator pass configuration.
267    ///
268    /// # Errors
269    /// Returns [`ServerError::ConfigValidation`] when the configured key is not
270    /// exactly one valid Ed25519 verifying key encoded as 64 hexadecimal digits.
271    pub(crate) fn from_config(config: &PassConfig) -> Result<Self, ServerError> {
272        let key = hex::decode(&config.registry_verifying_key).map_err(|error| {
273            ServerError::ConfigValidation {
274                message: format!(
275                    "auth.pass.registry_verifying_key: expected 64 hexadecimal digits: {error}"
276                ),
277            }
278        })?;
279        Self::new(&key, config.maximum_clock_skew_seconds).map_err(|_| {
280            ServerError::ConfigValidation {
281                message: "auth.pass.registry_verifying_key: expected a valid 32-byte Ed25519 verifying key".to_owned(),
282            }
283        })
284    }
285
286    /// Builds a verifier from operator configuration.
287    ///
288    /// # Errors
289    /// Returns [`PassCheckError::Malformed`] unless the key is exactly 32 bytes.
290    pub(crate) fn new(key: &[u8], skew: u64) -> Result<Self, PassCheckError> {
291        let key: [u8; 32] = key.try_into().map_err(|_| PassCheckError::Malformed)?;
292        let key = VerifyingKey::from_bytes(&key).map_err(|_| PassCheckError::Malformed)?;
293        Ok(Self {
294            key,
295            skew,
296            require_verified_worker: false,
297        })
298    }
299
300    /// Verifies `bytes` as a registry pass, and names its holder.
301    ///
302    /// # Errors
303    /// Returns the precise malformed, signature, or expired check failure.
304    pub(crate) fn verify(&self, bytes: &[u8]) -> Result<VerifiedPass, PassCheckError> {
305        let principal = WirePassV1::verify(bytes, &self.key, self.skew, SystemTime::now())?;
306        let holder = VerifiedHolder {
307            participant: principal.participant.clone(),
308            live: principal.live.clone(),
309            source: HolderSource::RegistryPass,
310        };
311        Ok(VerifiedPass { principal, holder })
312    }
313}
314
315impl WirePassV1 {
316    /// Encodes all signed fields in fixed order using big-endian lengths and integers.
317    ///
318    /// # Errors
319    /// Returns [`PassCheckError::Malformed`] for noncanonical scope or time ordering.
320    pub fn canonical_unsigned_bytes(&self) -> Result<Vec<u8>, PassCheckError> {
321        if self.issued_at > self.expires_at || self.conversations.windows(2).any(|p| p[0] >= p[1]) {
322            return Err(PassCheckError::Malformed);
323        }
324        let mut out = vec![PASS_VERSION_V1];
325        put_bytes(&mut out, &self.participant)?;
326        out.extend_from_slice(&self.public_key);
327        put_len(&mut out, self.conversations.len())?;
328        for id in &self.conversations {
329            out.extend_from_slice(&id.to_be_bytes());
330        }
331        put_bytes(&mut out, self.live.as_bytes())?;
332        out.push(u8::from(self.may_enroll));
333        out.extend_from_slice(&self.issued_at.to_be_bytes());
334        out.extend_from_slice(&self.expires_at.to_be_bytes());
335        Ok(out)
336    }
337
338    /// Encodes the complete pass including its signature.
339    ///
340    /// # Errors
341    /// Returns [`PassCheckError::Malformed`] for noncanonical input.
342    pub fn canonical_bytes(&self) -> Result<Vec<u8>, PassCheckError> {
343        let mut out = self.canonical_unsigned_bytes()?;
344        out.extend_from_slice(&self.signature);
345        Ok(out)
346    }
347
348    /// Parses canonical version-1 bytes.
349    ///
350    /// # Errors
351    /// Returns [`PassCheckError::Malformed`] for any format violation.
352    pub fn parse(bytes: &[u8]) -> Result<Self, PassCheckError> {
353        let unsigned_len = bytes
354            .len()
355            .checked_sub(SIGNATURE_LEN)
356            .ok_or(PassCheckError::Malformed)?;
357        let (unsigned, sig) = bytes.split_at(unsigned_len);
358        let mut c = Cursor(unsigned);
359        if c.u8()? != PASS_VERSION_V1 {
360            return Err(PassCheckError::Malformed);
361        }
362        let participant = c.bytes()?.to_vec();
363        let public_key = c.array()?;
364        let count = c.len()?;
365        // The count is the client's own claim, read before the signature is
366        // checked: it is bounded before it sizes any allocation.
367        if count > MAX_PASS_CONVERSATIONS {
368            return Err(PassCheckError::ScopeTooLarge);
369        }
370        let mut conversations = Vec::with_capacity(count);
371        for _ in 0..count {
372            conversations.push(c.u64()?);
373        }
374        let live = std::str::from_utf8(c.bytes()?)
375            .map_err(|_| PassCheckError::Malformed)?
376            .to_owned();
377        let may_enroll = match c.u8()? {
378            0 => false,
379            1 => true,
380            _ => return Err(PassCheckError::Malformed),
381        };
382        let issued_at = c.u64()?;
383        let expires_at = c.u64()?;
384        if !c.0.is_empty()
385            || issued_at > expires_at
386            || conversations.windows(2).any(|p| p[0] >= p[1])
387        {
388            return Err(PassCheckError::Malformed);
389        }
390        Ok(Self {
391            participant,
392            public_key,
393            conversations,
394            live,
395            may_enroll,
396            issued_at,
397            expires_at,
398            signature: sig.try_into().map_err(|_| PassCheckError::Malformed)?,
399        })
400    }
401
402    /// Verifies strict Ed25519 signature and the skew-bounded validity window.
403    ///
404    /// # Errors
405    /// Returns the precise malformed, signature, or expired check failure.
406    pub fn verify(
407        bytes: &[u8],
408        key: &VerifyingKey,
409        skew: u64,
410        now: SystemTime,
411    ) -> Result<PassPrincipal, PassCheckError> {
412        let pass = Self::parse(bytes)?;
413        key.verify_strict(
414            &pass.canonical_unsigned_bytes()?,
415            &Signature::from_bytes(&pass.signature),
416        )
417        .map_err(|_| PassCheckError::Signature)?;
418        let now = now
419            .duration_since(UNIX_EPOCH)
420            .map_err(|_| PassCheckError::Expired)?
421            .as_secs();
422        if now < pass.issued_at.saturating_sub(skew) || now > pass.expires_at.saturating_add(skew) {
423            return Err(PassCheckError::Expired);
424        }
425        Ok(PassPrincipal {
426            participant: pass.participant,
427            public_key: pass.public_key,
428            conversations: pass.conversations.into_iter().collect(),
429            live: pass.live,
430            may_enroll: pass.may_enroll,
431        })
432    }
433}
434
435fn put_len(out: &mut Vec<u8>, len: usize) -> Result<(), PassCheckError> {
436    out.extend_from_slice(
437        &u32::try_from(len)
438            .map_err(|_| PassCheckError::Malformed)?
439            .to_be_bytes(),
440    );
441    Ok(())
442}
443fn put_bytes(out: &mut Vec<u8>, bytes: &[u8]) -> Result<(), PassCheckError> {
444    put_len(out, bytes.len())?;
445    out.extend_from_slice(bytes);
446    Ok(())
447}
448struct Cursor<'a>(&'a [u8]);
449impl<'a> Cursor<'a> {
450    const fn take(&mut self, n: usize) -> Result<&'a [u8], PassCheckError> {
451        if self.0.len() < n {
452            return Err(PassCheckError::Malformed);
453        }
454        let (a, b) = self.0.split_at(n);
455        self.0 = b;
456        Ok(a)
457    }
458    fn u8(&mut self) -> Result<u8, PassCheckError> {
459        Ok(self.take(1)?[0])
460    }
461    fn len(&mut self) -> Result<usize, PassCheckError> {
462        let a: [u8; 4] = self
463            .take(4)?
464            .try_into()
465            .map_err(|_| PassCheckError::Malformed)?;
466        usize::try_from(u32::from_be_bytes(a)).map_err(|_| PassCheckError::Malformed)
467    }
468    fn u64(&mut self) -> Result<u64, PassCheckError> {
469        Ok(u64::from_be_bytes(
470            self.take(8)?
471                .try_into()
472                .map_err(|_| PassCheckError::Malformed)?,
473        ))
474    }
475    fn bytes(&mut self) -> Result<&'a [u8], PassCheckError> {
476        let n = self.len()?;
477        self.take(n)
478    }
479    fn array<const N: usize>(&mut self) -> Result<[u8; N], PassCheckError> {
480        self.take(N)?
481            .try_into()
482            .map_err(|_| PassCheckError::Malformed)
483    }
484}
485
486#[cfg(test)]
487mod debug_tests;
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    /// Unsigned-pass bytes up to and including a declared conversation count,
494    /// with no conversations after it, then a zero signature.
495    fn pass_declaring(count: u32) -> Vec<u8> {
496        let mut bytes = vec![PASS_VERSION_V1];
497        bytes.extend_from_slice(&0_u32.to_be_bytes());
498        bytes.extend_from_slice(&[0_u8; 32]);
499        bytes.extend_from_slice(&count.to_be_bytes());
500        bytes.extend_from_slice(&[0_u8; SIGNATURE_LEN]);
501        bytes
502    }
503
504    #[test]
505    fn a_pass_declaring_u32_max_conversations_is_refused_by_name_before_allocation() {
506        assert_eq!(
507            WirePassV1::parse(&pass_declaring(u32::MAX)),
508            Err(PassCheckError::ScopeTooLarge)
509        );
510        assert_eq!(
511            PassCheckError::ScopeTooLarge.check_name(),
512            "scope_too_large"
513        );
514    }
515
516    #[test]
517    fn a_pass_declaring_one_past_the_bound_is_refused_and_the_bound_itself_is_not()
518    -> Result<(), Box<dyn std::error::Error>> {
519        let at_bound = u32::try_from(MAX_PASS_CONVERSATIONS)?;
520        assert_eq!(
521            WirePassV1::parse(&pass_declaring(at_bound + 1)),
522            Err(PassCheckError::ScopeTooLarge)
523        );
524        // At the bound the count is admitted; the missing conversations then
525        // make the pass malformed.
526        assert_eq!(
527            WirePassV1::parse(&pass_declaring(at_bound)),
528            Err(PassCheckError::Malformed)
529        );
530        Ok(())
531    }
532
533    #[test]
534    fn shared_version_one_vector_is_byte_exact_and_verifies()
535    -> Result<(), Box<dyn std::error::Error>> {
536        let vector: serde_json::Value =
537            serde_json::from_str(include_str!("../test-vectors/wire-pass-v1.json"))?;
538        let unsigned = hex::decode(
539            vector["unsigned_pass_hex"]
540                .as_str()
541                .ok_or("unsigned bytes")?,
542        )?;
543        let signature: [u8; 64] =
544            hex::decode(vector["signature_hex"].as_str().ok_or("signature")?)?
545                .try_into()
546                .map_err(|_| "signature length")?;
547        let bytes = hex::decode(vector["pass_hex"].as_str().ok_or("pass bytes")?)?;
548        let registry_key: [u8; 32] = hex::decode(
549            vector["registry_verifying_key_hex"]
550                .as_str()
551                .ok_or("verifying key")?,
552        )?
553        .try_into()
554        .map_err(|_| "key length")?;
555        let pass = WirePassV1::parse(&bytes).map_err(|error| format!("parse failed: {error:?}"))?;
556        assert_eq!(
557            pass.canonical_unsigned_bytes()
558                .map_err(|error| format!("encode failed: {error:?}"))?,
559            unsigned
560        );
561        assert_eq!(pass.signature, signature);
562        let key = VerifyingKey::from_bytes(&registry_key)?;
563        let principal = WirePassV1::verify(
564            &bytes,
565            &key,
566            0,
567            UNIX_EPOCH + std::time::Duration::from_secs(pass.issued_at),
568        )
569        .map_err(|error| format!("verify failed: {error:?}"))?;
570        assert_eq!(principal.participant, b"participant-42");
571        assert_eq!(
572            principal.conversations.into_iter().collect::<Vec<_>>(),
573            vec![7, 42, 9001]
574        );
575        assert_eq!(principal.live, "workspace/acme/");
576        assert!(principal.may_enroll);
577        Ok(())
578    }
579}