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