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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub enum TokenScope {
21 Read,
23 Write,
25}
26
27impl TokenScope {
28 #[must_use]
30 pub const fn allows_read(self) -> bool {
31 matches!(self, Self::Read | Self::Write)
32 }
33
34 #[must_use]
36 pub const fn allows_write(self) -> bool {
37 matches!(self, Self::Write)
38 }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43pub enum RepositoryProvider {
44 GitHub,
46 Gitea,
48 GitLab,
50 Codeberg,
52 Generic,
54}
55
56impl RepositoryProvider {
57 #[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#[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#[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 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 #[must_use]
128 pub const fn provider(&self) -> RepositoryProvider {
129 self.provider
130 }
131
132 #[must_use]
134 pub fn owner(&self) -> &str {
135 &self.owner
136 }
137
138 #[must_use]
140 pub fn name(&self) -> &str {
141 &self.name
142 }
143
144 #[must_use]
146 pub fn revision(&self) -> Option<&str> {
147 self.revision.as_deref()
148 }
149}
150
151#[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 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 #[must_use]
188 pub fn issuer(&self) -> &str {
189 &self.issuer
190 }
191
192 #[must_use]
194 pub fn subject(&self) -> &str {
195 &self.subject
196 }
197
198 #[must_use]
200 pub const fn scope(&self) -> TokenScope {
201 self.scope
202 }
203
204 #[must_use]
206 pub const fn repository(&self) -> &RepositoryScope {
207 &self.repository
208 }
209
210 #[must_use]
212 pub const fn expires_at_unix_seconds(&self) -> u64 {
213 self.expires_at_unix_seconds
214 }
215}
216
217#[derive(Debug, Clone, Copy, Error, PartialEq, Eq)]
219pub enum TokenClaimsError {
220 #[error("token issuer must not be empty")]
222 EmptyIssuer,
223 #[error("token subject must not be empty")]
225 EmptySubject,
226 #[error("token repository owner must not be empty")]
228 EmptyRepositoryOwner,
229 #[error("token repository name must not be empty")]
231 EmptyRepositoryName,
232 #[error("token revision must not be empty when provided")]
234 EmptyRevision,
235 #[error("token components must not contain control characters")]
237 ControlCharacter,
238 #[error("token component exceeded supported length")]
240 TooLong,
241}
242
243const MIN_SIGNING_KEY_BYTES: usize = 32;
245
246#[derive(Debug, Error)]
248pub enum TokenCodecError {
249 #[error("token signing key must not be empty")]
251 EmptySigningKey,
252 #[error("token signing key must be at least {MIN_SIGNING_KEY_BYTES} bytes")]
254 SigningKeyTooShort,
255 #[error("token json operation failed")]
257 Json(#[from] JsonError),
258 #[error("token format was invalid")]
260 InvalidFormat,
261 #[error("token hex segment was invalid")]
263 InvalidHex(#[from] hex::FromHexError),
264 #[error("token signature was invalid")]
266 InvalidSignature,
267 #[error("token has expired")]
269 Expired,
270 #[error("token claims were invalid")]
272 Claims(#[from] TokenClaimsError),
273}
274
275#[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 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 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 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 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}