syncular_testkit/
auth_lease.rs1use base64::engine::general_purpose::URL_SAFE_NO_PAD;
2use base64::Engine as _;
3use p256::ecdsa::signature::{Signer, Verifier};
4use p256::ecdsa::{Signature, SigningKey, VerifyingKey};
5use serde::de::DeserializeOwned;
6use syncular_runtime::protocol::{
7 AuthLeasePayload, AuthLeaseProtectedHeader, AuthLeaseValidationResult, AUTH_LEASE_ALG_ES256,
8 AUTH_LEASE_CODE_EXPIRED, AUTH_LEASE_CODE_INVALID, AUTH_LEASE_TYP,
9};
10
11#[derive(Clone)]
12pub struct TestAuthLeaseKeyPair {
13 kid: String,
14 signing_key: SigningKey,
15 verifying_key: VerifyingKey,
16}
17
18#[derive(Debug, Clone, PartialEq)]
19pub struct VerifiedTestAuthLease {
20 pub header: AuthLeaseProtectedHeader,
21 pub payload: AuthLeasePayload,
22}
23
24impl TestAuthLeaseKeyPair {
25 pub fn deterministic(kid: impl Into<String>) -> Self {
26 let signing_key = SigningKey::from_slice(&[
27 7, 42, 19, 88, 193, 54, 21, 77, 99, 101, 12, 204, 33, 15, 76, 145, 9, 111, 7, 62, 188,
28 10, 222, 44, 72, 3, 170, 81, 94, 6, 23, 209,
29 ])
30 .expect("deterministic test auth lease key");
31 let verifying_key = *signing_key.verifying_key();
32 Self {
33 kid: kid.into(),
34 signing_key,
35 verifying_key,
36 }
37 }
38
39 pub fn kid(&self) -> &str {
40 &self.kid
41 }
42
43 pub fn verifying_key(&self) -> &VerifyingKey {
44 &self.verifying_key
45 }
46}
47
48impl Default for TestAuthLeaseKeyPair {
49 fn default() -> Self {
50 Self::deterministic("syncular-test-lease-key")
51 }
52}
53
54pub fn issue_test_auth_lease(payload: &AuthLeasePayload, key: &TestAuthLeaseKeyPair) -> String {
55 let header = AuthLeaseProtectedHeader::es256(key.kid());
56 let signing_input = format!(
57 "{}.{}",
58 encode_json_segment(&header),
59 encode_json_segment(payload)
60 );
61 let signature: Signature = key.signing_key.sign(signing_input.as_bytes());
62 format!(
63 "{}.{}",
64 signing_input,
65 URL_SAFE_NO_PAD.encode(signature.to_bytes())
66 )
67}
68
69pub fn verify_test_auth_lease(
70 token: &str,
71 verifying_key: &VerifyingKey,
72 now_ms: i64,
73) -> Result<VerifiedTestAuthLease, AuthLeaseValidationResult> {
74 let parts = token.split('.').collect::<Vec<_>>();
75 if parts.len() != 3 {
76 return Err(invalid("auth lease token must have three JWS segments"));
77 }
78
79 let header: AuthLeaseProtectedHeader = decode_json_segment(parts[0])?;
80 if header.alg != AUTH_LEASE_ALG_ES256 || header.typ != AUTH_LEASE_TYP {
81 return Err(invalid("auth lease token has unsupported protected header"));
82 }
83
84 let signature = URL_SAFE_NO_PAD
85 .decode(parts[2])
86 .ok()
87 .and_then(|bytes| Signature::from_slice(&bytes).ok())
88 .ok_or_else(|| invalid("auth lease signature segment is invalid"))?;
89 let signing_input = format!("{}.{}", parts[0], parts[1]);
90 verifying_key
91 .verify(signing_input.as_bytes(), &signature)
92 .map_err(|_| invalid("auth lease signature verification failed"))?;
93
94 let payload: AuthLeasePayload = decode_json_segment(parts[1])?;
95 let skew = payload.max_clock_skew_ms.max(0);
96 if now_ms + skew < payload.not_before_ms {
97 return Err(AuthLeaseValidationResult::rejected(
98 AUTH_LEASE_CODE_INVALID,
99 "auth lease is not valid yet",
100 ));
101 }
102 if now_ms - skew > payload.expires_at_ms {
103 let mut result =
104 AuthLeaseValidationResult::rejected(AUTH_LEASE_CODE_EXPIRED, "auth lease is expired");
105 result.lease_id = Some(payload.lease_id);
106 result.kid = Some(header.kid);
107 result.expires_at_ms = Some(payload.expires_at_ms);
108 return Err(result);
109 }
110
111 Ok(VerifiedTestAuthLease { header, payload })
112}
113
114fn encode_json_segment<T: serde::Serialize>(value: &T) -> String {
115 let json = serde_json::to_vec(value).expect("auth lease JSON segment");
116 URL_SAFE_NO_PAD.encode(json)
117}
118
119fn decode_json_segment<T: DeserializeOwned>(segment: &str) -> Result<T, AuthLeaseValidationResult> {
120 let bytes = URL_SAFE_NO_PAD
121 .decode(segment)
122 .map_err(|_| invalid("auth lease JSON segment is not base64url"))?;
123 serde_json::from_slice(&bytes).map_err(|_| invalid("auth lease JSON segment is invalid"))
124}
125
126fn invalid(message: impl Into<String>) -> AuthLeaseValidationResult {
127 AuthLeaseValidationResult::rejected(AUTH_LEASE_CODE_INVALID, message)
128}