Skip to main content

lexe_common/api/
revocable_clients.rs

1//! Information about a client
2
3use std::collections::HashMap;
4
5use anyhow::anyhow;
6use lexe_crypto::ed25519;
7use lexe_serde::{
8    base64_or_bytes,
9    optopt::{self, none},
10};
11#[cfg(any(test, feature = "test-utils"))]
12use proptest_derive::Arbitrary;
13use serde::{Deserialize, Serialize};
14
15use crate::{
16    api::{
17        auth::{BearerAuthToken, LexeScope},
18        user::UserPk,
19    },
20    time::TimestampMs,
21};
22
23/// All revocable clients which have ever been created.
24///
25/// This struct must be persisted in a rollback-resistant data store.
26// We don't *really* need to persist revoked clients but might as well keep them
27// around for historical reference. We can prune them later if needed.
28#[derive(Clone, Debug, Default, Serialize, Deserialize)]
29pub struct RevocableClients {
30    pub clients: HashMap<ed25519::PublicKey, RevocableClient>,
31}
32
33impl RevocableClients {
34    /// A user shouldn't need more than 100 clients.
35    pub const MAX_LEN: usize = 100;
36
37    /// An iterator over all clients which are valid right now.
38    pub fn iter_valid(
39        &self,
40    ) -> impl Iterator<Item = (&ed25519::PublicKey, &RevocableClient)> {
41        self.iter_valid_at(TimestampMs::now())
42    }
43
44    /// An iterator over all clients which are valid at the given time.
45    pub fn iter_valid_at(
46        &self,
47        now: TimestampMs,
48    ) -> impl Iterator<Item = (&ed25519::PublicKey, &RevocableClient)> {
49        self.clients
50            .iter()
51            .filter(|(_k, v)| !v.is_revoked)
52            .filter(move |(_k, v)| !v.is_expired_at(now))
53    }
54}
55
56/// Information about a revocable client.
57/// Each client is issued a `RevocableClientCert` whose pubkey is saved here.
58#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
59#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
60pub struct RevocableClient {
61    /// The client's cert pubkey.
62    // TODO(max): In the future, bearer auth tokens could be issued to this pk.
63    pub pubkey: ed25519::PublicKey,
64    /// When we first issued the client cert and created this client.
65    pub created_at: TimestampMs,
66    /// The time after which the server will no longer accept this client.
67    /// [`None`] indicates that the client will never expire (use carefully!).
68    /// This expiration time can be extended at any time.
69    pub expires_at: Option<TimestampMs>,
70    /// Optional user-provided label for this client.
71    #[cfg_attr(
72        any(test, feature = "test-utils"),
73        proptest(strategy = "arb::any_label()")
74    )]
75    pub label: Option<String>,
76    /// The authorization scopes allowed for this client.
77    // TODO(max): This scope is currently ineffective.
78    pub scope: LexeScope,
79    /// Whether this client has been revoked. Revocation is permanent.
80    pub is_revoked: bool,
81    // TODO(phlip9): add "pausing" a client's access temporarily?
82}
83
84impl RevocableClient {
85    /// Limit label length to 64 bytes
86    pub const MAX_LABEL_LEN: usize = 64;
87
88    /// Whether the client is valid at a given time (not revoked, not expired).
89    #[must_use]
90    pub fn is_valid_at(&self, now: TimestampMs) -> bool {
91        !self.is_revoked && !self.is_expired_at(now)
92    }
93
94    /// Whether the client is expired at the given time.
95    #[must_use]
96    pub fn is_expired_at(&self, now: TimestampMs) -> bool {
97        if let Some(expiration) = self.expires_at
98            && now > expiration
99        {
100            return true;
101        }
102
103        false
104    }
105}
106
107/// A request to list all revocable clients.
108#[derive(Clone, Debug, Serialize, Deserialize)]
109#[cfg_attr(any(test, feature = "test-utils"), derive(Eq, PartialEq, Arbitrary))]
110pub struct GetRevocableClients {
111    /// Whether to return only clients which are currently valid.
112    pub valid_only: bool,
113}
114
115/// A request to create a new revocable client.
116#[derive(Clone, Debug, Serialize, Deserialize)]
117pub struct CreateRevocableClientRequest {
118    /// The expiration after which the node should reject this client.
119    /// [`None`] indicates that the client will never expire (use carefully!).
120    pub expires_at: Option<TimestampMs>,
121    /// Optional user-provided label for this client.
122    pub label: Option<String>,
123    /// The authorization scopes allowed for this client.
124    pub scope: LexeScope,
125}
126
127/// The response to [`CreateRevocableClientRequest`].
128#[derive(Clone, Debug, Serialize, Deserialize)]
129pub struct CreateRevocableClientResponse {
130    /// The user public key associated with these credentials.
131    /// Always `Some` since `node-v0.8.11`.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub user_pk: Option<UserPk>,
134
135    /// The client cert pubkey.
136    pub pubkey: ed25519::PublicKey,
137
138    /// When this client was created.
139    pub created_at: TimestampMs,
140
141    /// The DER-encoded ephemeral issuing CA cert that the client should trust.
142    ///
143    /// This is just packaged alongside the rest for convenience.
144    // NOTE: This client cert goes *last* in the cert chain given to rustls.
145    #[serde(with = "base64_or_bytes")]
146    pub eph_ca_cert_der: Vec<u8>,
147
148    /// The DER-encoded client cert to present when connecting to the node.
149    // NOTE: This client cert goes *first* in the cert chain given to rustls.
150    #[serde(with = "base64_or_bytes")]
151    pub rev_client_cert_der: Vec<u8>,
152
153    /// The DER-encoded client cert key.
154    #[serde(with = "base64_or_bytes")]
155    pub rev_client_cert_key_der: Vec<u8>,
156
157    /// A long-lived [`LexeScope::GatewayProxy`] token for connecting to the
158    /// user's node via the gateway proxy. Always `Some` for user nodes.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub gateway_proxy_token: Option<BearerAuthToken>,
161}
162
163/// A request to update a single [`RevocableClient`].
164///
165/// All fields except `pubkey` are optional. If a field is `None`, it will not
166/// be updated. For example:
167///
168/// * `expires_at: None` -> don't change
169/// * `expires_at: Some(None)` -> set to never expire
170/// * `expires_at: Some(TimestampMs(..))` -> set to expire at that time
171#[derive(Serialize, Deserialize)]
172#[cfg_attr(test, derive(Debug, Eq, PartialEq, Arbitrary))]
173pub struct UpdateClientRequest {
174    /// The pubkey of the client to update.
175    pub pubkey: ed25519::PublicKey,
176
177    /// Set this client's expiration (`Some(None)` means never expire).
178    #[serde(default, skip_serializing_if = "none", with = "optopt")]
179    pub expires_at: Option<Option<TimestampMs>>,
180
181    /// Set this client's label.
182    #[serde(default, skip_serializing_if = "none", with = "optopt")]
183    #[cfg_attr(test, proptest(strategy = "arb::any_label_update()"))]
184    pub label: Option<Option<String>>,
185
186    /// Set the authorization scopes allowed for this client.
187    #[serde(skip_serializing_if = "none")]
188    pub scope: Option<LexeScope>,
189
190    /// Set this to revoke or unrevoke the client. Revocation is permanent, so
191    /// you cannot unrevoke a client once it is revoked.
192    #[serde(skip_serializing_if = "none")]
193    pub is_revoked: Option<bool>,
194}
195
196/// The updated [`RevocableClient`] after a successful update.
197#[derive(Serialize, Deserialize)]
198pub struct UpdateClientResponse {
199    pub client: RevocableClient,
200}
201
202impl RevocableClient {
203    /// Apply an update to this client, returning a copy with updates applied.
204    pub fn update(&self, req: UpdateClientRequest) -> anyhow::Result<Self> {
205        let UpdateClientRequest {
206            pubkey: req_pubkey,
207            expires_at: req_expires_at,
208            label: req_label,
209            scope: req_scope,
210            is_revoked: req_is_revoked,
211        } = req;
212
213        let mut out = self.clone();
214
215        if self.pubkey != req_pubkey {
216            debug_assert!(false);
217            return Err(anyhow!("Cannot update a different client"));
218        }
219
220        if let Some(expires_at) = req_expires_at {
221            // TODO(max): Maybe need some validation here
222            out.expires_at = expires_at;
223        }
224
225        if let Some(maybe_label) = req_label {
226            if let Some(label) = &maybe_label
227                && label.len() > Self::MAX_LABEL_LEN
228            {
229                return Err(anyhow!(
230                    "Label must not be longer than {} bytes",
231                    Self::MAX_LABEL_LEN,
232                ));
233            }
234            out.label = maybe_label;
235        }
236
237        if let Some(scope) = req_scope {
238            // TODO(max): Need some validation here; can't request broader
239            // scope, only some clients can call, etc.
240            out.scope = scope;
241        }
242
243        if let Some(revoke) = req_is_revoked {
244            if self.is_revoked && !revoke {
245                return Err(anyhow!("Cannot unrevoke a client"));
246            }
247            out.is_revoked = revoke;
248        }
249
250        Ok(out)
251    }
252}
253
254#[cfg(any(test, feature = "test-utils"))]
255mod arb {
256    use std::ops::RangeInclusive;
257
258    use proptest::{collection::vec, option, strategy::Strategy};
259
260    use super::*;
261    use crate::test_utils::arbitrary;
262
263    pub fn any_label() -> impl Strategy<Value = Option<String>> {
264        static RANGES: &[RangeInclusive<char>] =
265            &['0'..='9', 'A'..='Z', 'a'..='z'];
266        let any_alphanum_char = proptest::char::ranges(RANGES.into());
267        option::of(
268            vec(any_alphanum_char, 0..=RevocableClient::MAX_LABEL_LEN)
269                .prop_map(String::from_iter),
270        )
271    }
272
273    #[allow(dead_code)]
274    pub fn any_label_update() -> impl Strategy<Value = Option<Option<String>>> {
275        option::of(arbitrary::any_option_simple_string())
276    }
277}
278
279#[cfg(test)]
280mod test {
281    use super::*;
282    use crate::{root_seed::RootSeed, test_utils::roundtrip};
283
284    #[test]
285    fn rev_client_ser_basic() {
286        let client1 = RevocableClient {
287            pubkey: *RootSeed::from_u64(1).derive_user_key_pair().public_key(),
288            created_at: TimestampMs::from_secs_u32(69),
289            expires_at: Some(TimestampMs::from_secs_u32(420)),
290            label: Some("deez".to_string()),
291            scope: LexeScope::All,
292            is_revoked: false,
293        };
294        let client_json = serde_json::to_string_pretty(&client1).unwrap();
295        // println!("{client_json}");
296        let client_json_snapshot = r#"{
297  "pubkey": "aa8e3e1a9bffdb073507f23474100619fdd4e392ef0ff1e89348252f287a06fc",
298  "created_at": 69000,
299  "expires_at": 420000,
300  "label": "deez",
301  "scope": "All",
302  "is_revoked": false
303}"#;
304        assert_eq!(client_json, client_json_snapshot);
305
306        let client2 =
307            serde_json::from_str::<RevocableClient>(&client_json).unwrap();
308        assert_eq!(client1, client2);
309    }
310
311    #[test]
312    fn test_update_request_serde() {
313        roundtrip::json_string_roundtrip_proptest::<UpdateClientRequest>();
314    }
315
316    #[test]
317    fn test_get_revocable_clients_serde() {
318        roundtrip::query_string_roundtrip_proptest::<GetRevocableClients>();
319    }
320}