use std::collections::HashMap;
use anyhow::anyhow;
use lexe_crypto::ed25519;
use lexe_serde::{
base64_or_bytes,
optopt::{self, none},
};
#[cfg(any(test, feature = "test-utils"))]
use proptest_derive::Arbitrary;
use serde::{Deserialize, Serialize};
use crate::{
api::{auth::Scope, user::UserPk},
time::TimestampMs,
};
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct RevocableClients {
pub clients: HashMap<ed25519::PublicKey, RevocableClient>,
}
impl RevocableClients {
pub const MAX_LEN: usize = 100;
pub fn iter_valid(
&self,
) -> impl Iterator<Item = (&ed25519::PublicKey, &RevocableClient)> {
self.iter_valid_at(TimestampMs::now())
}
pub fn iter_valid_at(
&self,
now: TimestampMs,
) -> impl Iterator<Item = (&ed25519::PublicKey, &RevocableClient)> {
self.clients
.iter()
.filter(|(_k, v)| !v.is_revoked)
.filter(move |(_k, v)| !v.is_expired_at(now))
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
pub struct RevocableClient {
pub pubkey: ed25519::PublicKey,
pub created_at: TimestampMs,
pub expires_at: Option<TimestampMs>,
#[cfg_attr(
any(test, feature = "test-utils"),
proptest(strategy = "arb::any_label()")
)]
pub label: Option<String>,
pub scope: Scope,
pub is_revoked: bool,
}
impl RevocableClient {
pub const MAX_LABEL_LEN: usize = 64;
#[must_use]
pub fn is_valid_at(&self, now: TimestampMs) -> bool {
!self.is_revoked && !self.is_expired_at(now)
}
#[must_use]
pub fn is_expired_at(&self, now: TimestampMs) -> bool {
if let Some(expiration) = self.expires_at
&& now > expiration
{
return true;
}
false
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(any(test, feature = "test-utils"), derive(Eq, PartialEq, Arbitrary))]
pub struct GetRevocableClients {
pub valid_only: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CreateRevocableClientRequest {
pub expires_at: Option<TimestampMs>,
pub label: Option<String>,
pub scope: Scope,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CreateRevocableClientResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub user_pk: Option<UserPk>,
pub pubkey: ed25519::PublicKey,
pub created_at: TimestampMs,
#[serde(with = "base64_or_bytes")]
pub eph_ca_cert_der: Vec<u8>,
#[serde(with = "base64_or_bytes")]
pub rev_client_cert_der: Vec<u8>,
#[serde(with = "base64_or_bytes")]
pub rev_client_cert_key_der: Vec<u8>,
}
#[derive(Serialize, Deserialize)]
#[cfg_attr(test, derive(Debug, Eq, PartialEq, Arbitrary))]
pub struct UpdateClientRequest {
pub pubkey: ed25519::PublicKey,
#[serde(default, skip_serializing_if = "none", with = "optopt")]
pub expires_at: Option<Option<TimestampMs>>,
#[serde(default, skip_serializing_if = "none", with = "optopt")]
#[cfg_attr(test, proptest(strategy = "arb::any_label_update()"))]
pub label: Option<Option<String>>,
#[serde(skip_serializing_if = "none")]
pub scope: Option<Scope>,
#[serde(skip_serializing_if = "none")]
pub is_revoked: Option<bool>,
}
#[derive(Serialize, Deserialize)]
pub struct UpdateClientResponse {
pub client: RevocableClient,
}
impl RevocableClient {
pub fn update(&self, req: UpdateClientRequest) -> anyhow::Result<Self> {
let UpdateClientRequest {
pubkey: req_pubkey,
expires_at: req_expires_at,
label: req_label,
scope: req_scope,
is_revoked: req_is_revoked,
} = req;
let mut out = self.clone();
if self.pubkey != req_pubkey {
debug_assert!(false);
return Err(anyhow!("Cannot update a different client"));
}
if let Some(expires_at) = req_expires_at {
out.expires_at = expires_at;
}
if let Some(maybe_label) = req_label {
if let Some(label) = &maybe_label
&& label.len() > Self::MAX_LABEL_LEN
{
return Err(anyhow!(
"Label must not be longer than {} bytes",
Self::MAX_LABEL_LEN,
));
}
out.label = maybe_label;
}
if let Some(scope) = req_scope {
out.scope = scope;
}
if let Some(revoke) = req_is_revoked {
if self.is_revoked && !revoke {
return Err(anyhow!("Cannot unrevoke a client"));
}
out.is_revoked = revoke;
}
Ok(out)
}
}
#[cfg(any(test, feature = "test-utils"))]
mod arb {
use std::ops::RangeInclusive;
use proptest::{collection::vec, option, strategy::Strategy};
use super::*;
use crate::test_utils::arbitrary;
pub fn any_label() -> impl Strategy<Value = Option<String>> {
static RANGES: &[RangeInclusive<char>] =
&['0'..='9', 'A'..='Z', 'a'..='z'];
let any_alphanum_char = proptest::char::ranges(RANGES.into());
option::of(
vec(any_alphanum_char, 0..=RevocableClient::MAX_LABEL_LEN)
.prop_map(String::from_iter),
)
}
#[allow(dead_code)]
pub fn any_label_update() -> impl Strategy<Value = Option<Option<String>>> {
option::of(arbitrary::any_option_simple_string())
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::{root_seed::RootSeed, test_utils::roundtrip};
#[test]
fn rev_client_ser_basic() {
let client1 = RevocableClient {
pubkey: *RootSeed::from_u64(1).derive_user_key_pair().public_key(),
created_at: TimestampMs::from_secs_u32(69),
expires_at: Some(TimestampMs::from_secs_u32(420)),
label: Some("deez".to_string()),
scope: Scope::All,
is_revoked: false,
};
let client_json = serde_json::to_string_pretty(&client1).unwrap();
let client_json_snapshot = r#"{
"pubkey": "aa8e3e1a9bffdb073507f23474100619fdd4e392ef0ff1e89348252f287a06fc",
"created_at": 69000,
"expires_at": 420000,
"label": "deez",
"scope": "All",
"is_revoked": false
}"#;
assert_eq!(client_json, client_json_snapshot);
let client2 =
serde_json::from_str::<RevocableClient>(&client_json).unwrap();
assert_eq!(client1, client2);
}
#[test]
fn test_update_request_serde() {
roundtrip::json_string_roundtrip_proptest::<UpdateClientRequest>();
}
#[test]
fn test_get_revocable_clients_serde() {
roundtrip::query_string_roundtrip_proptest::<GetRevocableClients>();
}
}