Skip to main content

shardline_protocol/
token.rs

1use std::{fmt, str::FromStr};
2
3use hmac::{Hmac, Mac};
4use serde::{Deserialize, Serialize};
5use serde_json::{Error as JsonError, from_slice, to_vec};
6use subtle::ConstantTimeEq;
7use thiserror::Error;
8
9use crate::{SecretBytes, unix_now_seconds_lossy};
10
11type TokenMac = Hmac<sha2::Sha256>;
12
13const MAX_TOKEN_COMPONENT_BYTES: usize = 512;
14const MAX_TOKEN_STRING_BYTES: usize = 8192;
15const TOKEN_SIGNATURE_HEX_BYTES: usize = 64;
16const MAX_TOKEN_PAYLOAD_HEX_BYTES: usize = MAX_TOKEN_STRING_BYTES - TOKEN_SIGNATURE_HEX_BYTES - 1;
17
18/// CAS token scope.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub enum TokenScope {
21    /// Read-only CAS access.
22    Read,
23    /// Write access, including the read behavior required by upload clients.
24    Write,
25}
26
27impl TokenScope {
28    /// Returns true when this scope can perform read operations.
29    #[must_use]
30    pub const fn allows_read(self) -> bool {
31        matches!(self, Self::Read | Self::Write)
32    }
33
34    /// Returns true when this scope can perform write operations.
35    #[must_use]
36    pub const fn allows_write(self) -> bool {
37        matches!(self, Self::Write)
38    }
39}
40
41/// Provider family encoded into a scoped token.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43pub enum RepositoryProvider {
44    /// GitHub repository scope.
45    GitHub,
46    /// Gitea repository scope.
47    Gitea,
48    /// GitLab repository scope.
49    GitLab,
50    /// Codeberg (Gitea-based) repository scope.
51    Codeberg,
52    /// Generic Git forge repository scope.
53    Generic,
54}
55
56impl RepositoryProvider {
57    /// Returns the stable lowercase provider name used in persisted metadata.
58    #[must_use]
59    pub const fn as_str(self) -> &'static str {
60        match self {
61            Self::GitHub => "github",
62            Self::Gitea => "gitea",
63            Self::GitLab => "gitlab",
64            Self::Codeberg => "codeberg",
65            Self::Generic => "generic",
66        }
67    }
68}
69
70/// Repository provider parse failure.
71#[derive(Debug, Clone, Copy, Error, PartialEq, Eq)]
72#[error("repository provider was invalid")]
73pub struct RepositoryProviderParseError;
74
75impl FromStr for RepositoryProvider {
76    type Err = RepositoryProviderParseError;
77
78    fn from_str(value: &str) -> Result<Self, Self::Err> {
79        match value {
80            "github" => Ok(Self::GitHub),
81            "gitea" => Ok(Self::Gitea),
82            "gitlab" => Ok(Self::GitLab),
83            "codeberg" => Ok(Self::Codeberg),
84            "generic" => Ok(Self::Generic),
85            _other => Err(RepositoryProviderParseError),
86        }
87    }
88}
89
90/// Repository and revision scope encoded into a token.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct RepositoryScope {
93    provider: RepositoryProvider,
94    owner: String,
95    name: String,
96    revision: Option<String>,
97}
98
99impl RepositoryScope {
100    /// Creates a repository scope.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`TokenClaimsError`] when the owner, name, or revision contain invalid
105    /// values.
106    pub fn new(
107        provider: RepositoryProvider,
108        owner: &str,
109        name: &str,
110        revision: Option<&str>,
111    ) -> Result<Self, TokenClaimsError> {
112        validate_component(owner, TokenClaimsError::EmptyRepositoryOwner)?;
113        validate_component(name, TokenClaimsError::EmptyRepositoryName)?;
114        if let Some(value) = revision {
115            validate_component(value, TokenClaimsError::EmptyRevision)?;
116        }
117
118        Ok(Self {
119            provider,
120            owner: owner.to_owned(),
121            name: name.to_owned(),
122            revision: revision.map(ToOwned::to_owned),
123        })
124    }
125
126    /// Returns the scoped provider family.
127    #[must_use]
128    pub const fn provider(&self) -> RepositoryProvider {
129        self.provider
130    }
131
132    /// Returns the scoped repository owner or namespace.
133    #[must_use]
134    pub fn owner(&self) -> &str {
135        &self.owner
136    }
137
138    /// Returns the scoped repository name.
139    #[must_use]
140    pub fn name(&self) -> &str {
141        &self.name
142    }
143
144    /// Returns the scoped revision, when one is required.
145    #[must_use]
146    pub fn revision(&self) -> Option<&str> {
147        self.revision.as_deref()
148    }
149}
150
151/// Signed token claims used by the Shardline API.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct TokenClaims {
154    issuer: String,
155    subject: String,
156    scope: TokenScope,
157    repository: RepositoryScope,
158    expires_at_unix_seconds: u64,
159}
160
161impl TokenClaims {
162    /// Creates token claims.
163    ///
164    /// # Errors
165    ///
166    /// Returns [`TokenClaimsError`] when the issuer, subject, or repository scope are
167    /// invalid.
168    pub fn new(
169        issuer: &str,
170        subject: &str,
171        scope: TokenScope,
172        repository: RepositoryScope,
173        expires_at_unix_seconds: u64,
174    ) -> Result<Self, TokenClaimsError> {
175        validate_component(issuer, TokenClaimsError::EmptyIssuer)?;
176        validate_component(subject, TokenClaimsError::EmptySubject)?;
177        Ok(Self {
178            issuer: issuer.to_owned(),
179            subject: subject.to_owned(),
180            scope,
181            repository,
182            expires_at_unix_seconds,
183        })
184    }
185
186    /// Returns the token issuer identity.
187    #[must_use]
188    pub fn issuer(&self) -> &str {
189        &self.issuer
190    }
191
192    /// Returns the authenticated subject.
193    #[must_use]
194    pub fn subject(&self) -> &str {
195        &self.subject
196    }
197
198    /// Returns the granted scope.
199    #[must_use]
200    pub const fn scope(&self) -> TokenScope {
201        self.scope
202    }
203
204    /// Returns the scoped repository identity.
205    #[must_use]
206    pub const fn repository(&self) -> &RepositoryScope {
207        &self.repository
208    }
209
210    /// Returns the token expiration timestamp as Unix seconds.
211    #[must_use]
212    pub const fn expires_at_unix_seconds(&self) -> u64 {
213        self.expires_at_unix_seconds
214    }
215}
216
217/// Token claim validation failure.
218#[derive(Debug, Clone, Copy, Error, PartialEq, Eq)]
219pub enum TokenClaimsError {
220    /// The issuer was empty.
221    #[error("token issuer must not be empty")]
222    EmptyIssuer,
223    /// The subject was empty.
224    #[error("token subject must not be empty")]
225    EmptySubject,
226    /// The repository owner was empty.
227    #[error("token repository owner must not be empty")]
228    EmptyRepositoryOwner,
229    /// The repository name was empty.
230    #[error("token repository name must not be empty")]
231    EmptyRepositoryName,
232    /// The revision was empty.
233    #[error("token revision must not be empty when provided")]
234    EmptyRevision,
235    /// A token component contained control characters.
236    #[error("token components must not contain control characters")]
237    ControlCharacter,
238    /// A token component exceeded the supported metadata bound.
239    #[error("token component exceeded supported length")]
240    TooLong,
241}
242
243/// Minimum signing key length in bytes.
244const MIN_SIGNING_KEY_BYTES: usize = 32;
245
246/// Token signing or verification failure.
247#[derive(Debug, Error)]
248pub enum TokenCodecError {
249    /// The signing key was empty.
250    #[error("token signing key must not be empty")]
251    EmptySigningKey,
252    /// The signing key is too short.
253    #[error("token signing key must be at least {MIN_SIGNING_KEY_BYTES} bytes")]
254    SigningKeyTooShort,
255    /// The token payload could not be serialized or deserialized.
256    #[error("token json operation failed")]
257    Json(#[from] JsonError),
258    /// The token string did not match the expected format.
259    #[error("token format was invalid")]
260    InvalidFormat,
261    /// A hex-encoded token segment was malformed.
262    #[error("token hex segment was invalid")]
263    InvalidHex(#[from] hex::FromHexError),
264    /// The token signature did not verify.
265    #[error("token signature was invalid")]
266    InvalidSignature,
267    /// The token has expired.
268    #[error("token has expired")]
269    Expired,
270    /// The token payload contained invalid claims.
271    #[error("token claims were invalid")]
272    Claims(#[from] TokenClaimsError),
273}
274
275/// Local token signer and verifier.
276#[derive(Clone)]
277pub struct TokenSigner {
278    signing_key: SecretBytes,
279}
280
281impl fmt::Debug for TokenSigner {
282    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
283        formatter
284            .debug_struct("TokenSigner")
285            .field("signing_key", &"***")
286            .finish()
287    }
288}
289
290impl TokenSigner {
291    /// Creates a token signer from raw key bytes.
292    ///
293    /// # Errors
294    ///
295    /// Returns [`TokenCodecError::EmptySigningKey`] when the signing key is empty.
296    pub fn new(signing_key: &[u8]) -> Result<Self, TokenCodecError> {
297        if signing_key.is_empty() {
298            return Err(TokenCodecError::EmptySigningKey);
299        }
300        if signing_key.len() < MIN_SIGNING_KEY_BYTES {
301            return Err(TokenCodecError::SigningKeyTooShort);
302        }
303
304        Ok(Self {
305            signing_key: SecretBytes::from_slice(signing_key),
306        })
307    }
308
309    /// Signs token claims into an opaque bearer token string.
310    ///
311    /// # Errors
312    ///
313    /// Returns [`TokenCodecError`] when the claims cannot be serialized.
314    pub fn sign(&self, claims: &TokenClaims) -> Result<String, TokenCodecError> {
315        let payload = to_vec(claims)?;
316        let signature = self.signature(&payload)?;
317        Ok(format!(
318            "{}.{}",
319            hex::encode(payload),
320            hex::encode(signature)
321        ))
322    }
323
324    /// Verifies a token against the supplied current Unix timestamp.
325    ///
326    /// # Errors
327    ///
328    /// Returns [`TokenCodecError`] when the token does not parse, does not verify, or
329    /// has expired.
330    pub fn verify_at(
331        &self,
332        token: &str,
333        current_unix_seconds: u64,
334    ) -> Result<TokenClaims, TokenCodecError> {
335        if token.len() > MAX_TOKEN_STRING_BYTES {
336            return Err(TokenCodecError::InvalidFormat);
337        }
338        let Some((payload_hex, signature_hex)) = token.split_once('.') else {
339            return Err(TokenCodecError::InvalidFormat);
340        };
341        if payload_hex.is_empty()
342            || payload_hex.len() > MAX_TOKEN_PAYLOAD_HEX_BYTES
343            || signature_hex.len() != TOKEN_SIGNATURE_HEX_BYTES
344        {
345            return Err(TokenCodecError::InvalidFormat);
346        }
347        let payload = hex::decode(payload_hex)?;
348        let signature = hex::decode(signature_hex)?;
349        let expected_signature = self.signature(&payload)?;
350        if expected_signature.ct_eq(signature.as_slice()).unwrap_u8() != 1 {
351            return Err(TokenCodecError::InvalidSignature);
352        }
353
354        let claims = from_slice::<TokenClaims>(&payload)?;
355        validate_component(claims.issuer(), TokenClaimsError::EmptyIssuer)?;
356        validate_component(claims.subject(), TokenClaimsError::EmptySubject)?;
357        validate_component(
358            claims.repository().owner(),
359            TokenClaimsError::EmptyRepositoryOwner,
360        )?;
361        validate_component(
362            claims.repository().name(),
363            TokenClaimsError::EmptyRepositoryName,
364        )?;
365        if let Some(revision) = claims.repository().revision() {
366            validate_component(revision, TokenClaimsError::EmptyRevision)?;
367        }
368        if claims.expires_at_unix_seconds() < current_unix_seconds {
369            return Err(TokenCodecError::Expired);
370        }
371
372        Ok(claims)
373    }
374
375    /// Verifies a token against the current wall clock.
376    ///
377    /// # Errors
378    ///
379    /// Returns [`TokenCodecError`] when the token is invalid or expired.
380    pub fn verify_now(&self, token: &str) -> Result<TokenClaims, TokenCodecError> {
381        self.verify_at(token, unix_now_seconds_lossy())
382    }
383
384    fn signature(&self, payload: &[u8]) -> Result<Vec<u8>, TokenCodecError> {
385        let mut mac = TokenMac::new_from_slice(self.signing_key.expose_secret())
386            .map_err(|_error| TokenCodecError::EmptySigningKey)?;
387        mac.update(payload);
388        Ok(mac.finalize().into_bytes().to_vec())
389    }
390}
391
392fn validate_component(value: &str, empty_error: TokenClaimsError) -> Result<(), TokenClaimsError> {
393    if value.trim().is_empty() {
394        return Err(empty_error);
395    }
396
397    if value.len() > MAX_TOKEN_COMPONENT_BYTES {
398        return Err(TokenClaimsError::TooLong);
399    }
400
401    if value.chars().any(char::is_control) {
402        return Err(TokenClaimsError::ControlCharacter);
403    }
404
405    Ok(())
406}
407
408#[cfg(test)]
409mod tests {
410    use super::{
411        MAX_TOKEN_COMPONENT_BYTES, MAX_TOKEN_PAYLOAD_HEX_BYTES, RepositoryProvider,
412        RepositoryProviderParseError, RepositoryScope, TOKEN_SIGNATURE_HEX_BYTES, TokenClaims,
413        TokenClaimsError, TokenCodecError, TokenScope, TokenSigner,
414    };
415
416    #[test]
417    fn write_token_allows_read_and_write() {
418        assert!(TokenScope::Write.allows_read());
419        assert!(TokenScope::Write.allows_write());
420    }
421
422    #[test]
423    fn read_token_does_not_allow_write() {
424        assert!(TokenScope::Read.allows_read());
425        assert!(!TokenScope::Read.allows_write());
426    }
427
428    #[test]
429    fn repository_provider_parses_stable_names() {
430        assert_eq!("github".parse(), Ok(RepositoryProvider::GitHub));
431        assert_eq!("gitea".parse(), Ok(RepositoryProvider::Gitea));
432        assert_eq!("gitlab".parse(), Ok(RepositoryProvider::GitLab));
433        assert_eq!("codeberg".parse(), Ok(RepositoryProvider::Codeberg));
434        assert_eq!("generic".parse(), Ok(RepositoryProvider::Generic));
435        assert_eq!(
436            "bitbucket".parse::<RepositoryProvider>(),
437            Err(RepositoryProviderParseError)
438        );
439    }
440
441    #[test]
442    fn token_signer_debug_redacts_signing_key_material() {
443        let signer = TokenSigner::new(&[1; 32]);
444        assert!(signer.is_ok());
445        let Ok(signer) = signer else {
446            return;
447        };
448
449        let rendered = format!("{signer:?}");
450
451        assert!(!rendered.contains("[1, 2, 3, 4]"));
452        assert!(rendered.contains("***"));
453    }
454
455    #[test]
456    fn token_claims_reject_empty_subject() {
457        let repository =
458            RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"));
459        assert!(repository.is_ok());
460        let Ok(repository) = repository else {
461            return;
462        };
463        let claims = TokenClaims::new("issuer", " ", TokenScope::Read, repository, 42);
464
465        assert_eq!(claims, Err(TokenClaimsError::EmptySubject));
466    }
467
468    #[test]
469    fn repository_scope_rejects_empty_owner() {
470        let scope = RepositoryScope::new(RepositoryProvider::GitHub, "", "assets", Some("main"));
471
472        assert_eq!(scope, Err(TokenClaimsError::EmptyRepositoryOwner));
473    }
474
475    #[test]
476    fn repository_scope_rejects_oversized_components() {
477        let oversized = "o".repeat(MAX_TOKEN_COMPONENT_BYTES + 1);
478        let scope = RepositoryScope::new(RepositoryProvider::GitHub, &oversized, "assets", None);
479
480        assert_eq!(scope, Err(TokenClaimsError::TooLong));
481    }
482
483    #[test]
484    fn token_claims_reject_oversized_subject() {
485        let repository =
486            RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"));
487        assert!(repository.is_ok());
488        let Ok(repository) = repository else {
489            return;
490        };
491        let oversized = "s".repeat(MAX_TOKEN_COMPONENT_BYTES + 1);
492        let claims = TokenClaims::new("issuer", &oversized, TokenScope::Read, repository, 42);
493
494        assert_eq!(claims, Err(TokenClaimsError::TooLong));
495    }
496
497    #[test]
498    fn token_roundtrips_through_sign_and_verify() {
499        let signer = TokenSigner::new(b"test-signing-key-32-bytes-long!!");
500        assert!(signer.is_ok());
501        let Ok(signer) = signer else {
502            return;
503        };
504        let repository =
505            RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"));
506        assert!(repository.is_ok());
507        let Ok(repository) = repository else {
508            return;
509        };
510        let claims = TokenClaims::new(
511            "issuer",
512            "provider-user-1",
513            TokenScope::Write,
514            repository,
515            120,
516        );
517        assert!(claims.is_ok());
518        let Ok(claims) = claims else {
519            return;
520        };
521
522        let token = signer.sign(&claims);
523        assert!(token.is_ok());
524        let Ok(token) = token else {
525            return;
526        };
527        let verified = signer.verify_at(&token, 119);
528
529        assert!(verified.is_ok());
530        let Ok(verified) = verified else {
531            return;
532        };
533        assert_eq!(verified, claims);
534    }
535
536    #[test]
537    fn token_verify_rejects_tampering() {
538        let signer = TokenSigner::new(b"test-signing-key-32-bytes-long!!");
539        assert!(signer.is_ok());
540        let Ok(signer) = signer else {
541            return;
542        };
543        let repository =
544            RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"));
545        assert!(repository.is_ok());
546        let Ok(repository) = repository else {
547            return;
548        };
549        let claims = TokenClaims::new(
550            "issuer",
551            "provider-user-1",
552            TokenScope::Read,
553            repository,
554            120,
555        );
556        assert!(claims.is_ok());
557        let Ok(claims) = claims else {
558            return;
559        };
560        let token = signer.sign(&claims);
561        assert!(token.is_ok());
562        let Ok(token) = token else {
563            return;
564        };
565        let Some((payload, _signature)) = token.split_once('.') else {
566            return;
567        };
568        let tampered = format!("{payload}.{}", "00".repeat(32));
569
570        assert!(matches!(
571            signer.verify_at(&tampered, 119),
572            Err(TokenCodecError::InvalidSignature)
573        ));
574    }
575
576    #[test]
577    fn token_verify_rejects_oversized_token_before_hex_decoding() {
578        let signer = TokenSigner::new(b"test-signing-key-32-bytes-long!!");
579        assert!(signer.is_ok());
580        let Ok(signer) = signer else {
581            return;
582        };
583        let token = format!(
584            "{}.{}",
585            "a".repeat(MAX_TOKEN_PAYLOAD_HEX_BYTES + 1),
586            "0".repeat(TOKEN_SIGNATURE_HEX_BYTES)
587        );
588
589        assert!(matches!(
590            signer.verify_at(&token, 119),
591            Err(TokenCodecError::InvalidFormat)
592        ));
593    }
594
595    #[test]
596    fn token_verify_rejects_oversized_signature_before_hex_decoding() {
597        let signer = TokenSigner::new(b"test-signing-key-32-bytes-long!!");
598        assert!(signer.is_ok());
599        let Ok(signer) = signer else {
600            return;
601        };
602        let token = format!("{}.{}", "7b7d", "0".repeat(TOKEN_SIGNATURE_HEX_BYTES + 1));
603
604        assert!(matches!(
605            signer.verify_at(&token, 119),
606            Err(TokenCodecError::InvalidFormat)
607        ));
608    }
609
610    #[test]
611    fn token_verify_rejects_expired_tokens() {
612        let signer = TokenSigner::new(b"test-signing-key-32-bytes-long!!");
613        assert!(signer.is_ok());
614        let Ok(signer) = signer else {
615            return;
616        };
617        let repository =
618            RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"));
619        assert!(repository.is_ok());
620        let Ok(repository) = repository else {
621            return;
622        };
623        let claims = TokenClaims::new(
624            "issuer",
625            "provider-user-1",
626            TokenScope::Read,
627            repository,
628            120,
629        );
630        assert!(claims.is_ok());
631        let Ok(claims) = claims else {
632            return;
633        };
634        let token = signer.sign(&claims);
635        assert!(token.is_ok());
636        let Ok(token) = token else {
637            return;
638        };
639
640        assert!(matches!(
641            signer.verify_at(&token, 121),
642            Err(TokenCodecError::Expired)
643        ));
644    }
645}