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#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct PassPrincipal {
15    pub participant: Vec<u8>,
16    pub public_key: [u8; 32],
17    pub conversations: BTreeSet<u64>,
18    pub live: String,
19    pub may_enroll: bool,
20}
21
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct WirePassV1 {
24    pub participant: Vec<u8>,
25    pub public_key: [u8; 32],
26    pub conversations: Vec<u64>,
27    pub live: String,
28    pub may_enroll: bool,
29    pub issued_at: u64,
30    pub expires_at: u64,
31    pub signature: [u8; SIGNATURE_LEN],
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum PassCheckError {
36    Malformed,
37    Signature,
38    Expired,
39}
40impl PassCheckError {
41    #[must_use]
42    pub const fn check_name(self) -> &'static str {
43        match self {
44            Self::Malformed => "malformed",
45            Self::Signature => "signature",
46            Self::Expired => "expired",
47        }
48    }
49}
50
51#[derive(Clone, Debug)]
52pub(crate) struct PassVerifier {
53    key: VerifyingKey,
54    skew: u64,
55}
56impl PassVerifier {
57    /// Builds a verifier from the required operator pass configuration.
58    ///
59    /// # Errors
60    /// Returns [`ServerError::ConfigValidation`] when the configured key is not
61    /// exactly one valid Ed25519 verifying key encoded as 64 hexadecimal digits.
62    pub(crate) fn from_config(config: &PassConfig) -> Result<Self, ServerError> {
63        let key = hex::decode(&config.registry_verifying_key).map_err(|error| {
64            ServerError::ConfigValidation {
65                message: format!(
66                    "auth.pass.registry_verifying_key: expected 64 hexadecimal digits: {error}"
67                ),
68            }
69        })?;
70        Self::new(&key, config.maximum_clock_skew_seconds).map_err(|_| {
71            ServerError::ConfigValidation {
72                message: "auth.pass.registry_verifying_key: expected a valid 32-byte Ed25519 verifying key".to_owned(),
73            }
74        })
75    }
76
77    /// Builds a verifier from operator configuration.
78    ///
79    /// # Errors
80    /// Returns [`PassCheckError::Malformed`] unless the key is exactly 32 bytes.
81    pub(crate) fn new(key: &[u8], skew: u64) -> Result<Self, PassCheckError> {
82        let key: [u8; 32] = key.try_into().map_err(|_| PassCheckError::Malformed)?;
83        let key = VerifyingKey::from_bytes(&key).map_err(|_| PassCheckError::Malformed)?;
84        Ok(Self { key, skew })
85    }
86
87    pub(crate) fn verify(&self, bytes: &[u8]) -> Result<PassPrincipal, PassCheckError> {
88        WirePassV1::verify(bytes, &self.key, self.skew, SystemTime::now())
89    }
90}
91
92impl WirePassV1 {
93    /// Encodes all signed fields in fixed order using big-endian lengths and integers.
94    ///
95    /// # Errors
96    /// Returns [`PassCheckError::Malformed`] for noncanonical scope or time ordering.
97    pub fn canonical_unsigned_bytes(&self) -> Result<Vec<u8>, PassCheckError> {
98        if self.issued_at > self.expires_at || self.conversations.windows(2).any(|p| p[0] >= p[1]) {
99            return Err(PassCheckError::Malformed);
100        }
101        let mut out = vec![PASS_VERSION_V1];
102        put_bytes(&mut out, &self.participant)?;
103        out.extend_from_slice(&self.public_key);
104        put_len(&mut out, self.conversations.len())?;
105        for id in &self.conversations {
106            out.extend_from_slice(&id.to_be_bytes());
107        }
108        put_bytes(&mut out, self.live.as_bytes())?;
109        out.push(u8::from(self.may_enroll));
110        out.extend_from_slice(&self.issued_at.to_be_bytes());
111        out.extend_from_slice(&self.expires_at.to_be_bytes());
112        Ok(out)
113    }
114
115    /// Encodes the complete pass including its signature.
116    ///
117    /// # Errors
118    /// Returns [`PassCheckError::Malformed`] for noncanonical input.
119    pub fn canonical_bytes(&self) -> Result<Vec<u8>, PassCheckError> {
120        let mut out = self.canonical_unsigned_bytes()?;
121        out.extend_from_slice(&self.signature);
122        Ok(out)
123    }
124
125    /// Parses canonical version-1 bytes.
126    ///
127    /// # Errors
128    /// Returns [`PassCheckError::Malformed`] for any format violation.
129    pub fn parse(bytes: &[u8]) -> Result<Self, PassCheckError> {
130        let unsigned_len = bytes
131            .len()
132            .checked_sub(SIGNATURE_LEN)
133            .ok_or(PassCheckError::Malformed)?;
134        let (unsigned, sig) = bytes.split_at(unsigned_len);
135        let mut c = Cursor(unsigned);
136        if c.u8()? != PASS_VERSION_V1 {
137            return Err(PassCheckError::Malformed);
138        }
139        let participant = c.bytes()?.to_vec();
140        let public_key = c.array()?;
141        let count = c.len()?;
142        let mut conversations = Vec::with_capacity(count);
143        for _ in 0..count {
144            conversations.push(c.u64()?);
145        }
146        let live = std::str::from_utf8(c.bytes()?)
147            .map_err(|_| PassCheckError::Malformed)?
148            .to_owned();
149        let may_enroll = match c.u8()? {
150            0 => false,
151            1 => true,
152            _ => return Err(PassCheckError::Malformed),
153        };
154        let issued_at = c.u64()?;
155        let expires_at = c.u64()?;
156        if !c.0.is_empty()
157            || issued_at > expires_at
158            || conversations.windows(2).any(|p| p[0] >= p[1])
159        {
160            return Err(PassCheckError::Malformed);
161        }
162        Ok(Self {
163            participant,
164            public_key,
165            conversations,
166            live,
167            may_enroll,
168            issued_at,
169            expires_at,
170            signature: sig.try_into().map_err(|_| PassCheckError::Malformed)?,
171        })
172    }
173
174    /// Verifies strict Ed25519 signature and the skew-bounded validity window.
175    ///
176    /// # Errors
177    /// Returns the precise malformed, signature, or expired check failure.
178    pub fn verify(
179        bytes: &[u8],
180        key: &VerifyingKey,
181        skew: u64,
182        now: SystemTime,
183    ) -> Result<PassPrincipal, PassCheckError> {
184        let pass = Self::parse(bytes)?;
185        key.verify_strict(
186            &pass.canonical_unsigned_bytes()?,
187            &Signature::from_bytes(&pass.signature),
188        )
189        .map_err(|_| PassCheckError::Signature)?;
190        let now = now
191            .duration_since(UNIX_EPOCH)
192            .map_err(|_| PassCheckError::Expired)?
193            .as_secs();
194        if now < pass.issued_at.saturating_sub(skew) || now > pass.expires_at.saturating_add(skew) {
195            return Err(PassCheckError::Expired);
196        }
197        Ok(PassPrincipal {
198            participant: pass.participant,
199            public_key: pass.public_key,
200            conversations: pass.conversations.into_iter().collect(),
201            live: pass.live,
202            may_enroll: pass.may_enroll,
203        })
204    }
205}
206
207fn put_len(out: &mut Vec<u8>, len: usize) -> Result<(), PassCheckError> {
208    out.extend_from_slice(
209        &u32::try_from(len)
210            .map_err(|_| PassCheckError::Malformed)?
211            .to_be_bytes(),
212    );
213    Ok(())
214}
215fn put_bytes(out: &mut Vec<u8>, bytes: &[u8]) -> Result<(), PassCheckError> {
216    put_len(out, bytes.len())?;
217    out.extend_from_slice(bytes);
218    Ok(())
219}
220struct Cursor<'a>(&'a [u8]);
221impl<'a> Cursor<'a> {
222    const fn take(&mut self, n: usize) -> Result<&'a [u8], PassCheckError> {
223        if self.0.len() < n {
224            return Err(PassCheckError::Malformed);
225        }
226        let (a, b) = self.0.split_at(n);
227        self.0 = b;
228        Ok(a)
229    }
230    fn u8(&mut self) -> Result<u8, PassCheckError> {
231        Ok(self.take(1)?[0])
232    }
233    fn len(&mut self) -> Result<usize, PassCheckError> {
234        let a: [u8; 4] = self
235            .take(4)?
236            .try_into()
237            .map_err(|_| PassCheckError::Malformed)?;
238        usize::try_from(u32::from_be_bytes(a)).map_err(|_| PassCheckError::Malformed)
239    }
240    fn u64(&mut self) -> Result<u64, PassCheckError> {
241        Ok(u64::from_be_bytes(
242            self.take(8)?
243                .try_into()
244                .map_err(|_| PassCheckError::Malformed)?,
245        ))
246    }
247    fn bytes(&mut self) -> Result<&'a [u8], PassCheckError> {
248        let n = self.len()?;
249        self.take(n)
250    }
251    fn array<const N: usize>(&mut self) -> Result<[u8; N], PassCheckError> {
252        self.take(N)?
253            .try_into()
254            .map_err(|_| PassCheckError::Malformed)
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn shared_version_one_vector_is_byte_exact_and_verifies()
264    -> Result<(), Box<dyn std::error::Error>> {
265        let vector: serde_json::Value =
266            serde_json::from_str(include_str!("../test-vectors/wire-pass-v1.json"))?;
267        let unsigned = hex::decode(
268            vector["unsigned_pass_hex"]
269                .as_str()
270                .ok_or("unsigned bytes")?,
271        )?;
272        let signature: [u8; 64] =
273            hex::decode(vector["signature_hex"].as_str().ok_or("signature")?)?
274                .try_into()
275                .map_err(|_| "signature length")?;
276        let bytes = hex::decode(vector["pass_hex"].as_str().ok_or("pass bytes")?)?;
277        let registry_key: [u8; 32] = hex::decode(
278            vector["registry_verifying_key_hex"]
279                .as_str()
280                .ok_or("verifying key")?,
281        )?
282        .try_into()
283        .map_err(|_| "key length")?;
284        let pass = WirePassV1::parse(&bytes).map_err(|error| format!("parse failed: {error:?}"))?;
285        assert_eq!(
286            pass.canonical_unsigned_bytes()
287                .map_err(|error| format!("encode failed: {error:?}"))?,
288            unsigned
289        );
290        assert_eq!(pass.signature, signature);
291        let key = VerifyingKey::from_bytes(&registry_key)?;
292        let principal = WirePassV1::verify(
293            &bytes,
294            &key,
295            0,
296            UNIX_EPOCH + std::time::Duration::from_secs(pass.issued_at),
297        )
298        .map_err(|error| format!("verify failed: {error:?}"))?;
299        assert_eq!(principal.participant, b"participant-42");
300        assert_eq!(
301            principal.conversations.into_iter().collect::<Vec<_>>(),
302            vec![7, 42, 9001]
303        );
304        assert_eq!(principal.live, "workspace/acme/");
305        assert!(principal.may_enroll);
306        Ok(())
307    }
308}