use std::{fmt, str::FromStr};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use serde_json::{Error as JsonError, from_slice, to_vec};
use subtle::ConstantTimeEq;
use thiserror::Error;
use crate::{SecretBytes, unix_now_seconds_lossy};
type TokenMac = Hmac<sha2::Sha256>;
const MAX_TOKEN_COMPONENT_BYTES: usize = 512;
const MAX_TOKEN_STRING_BYTES: usize = 8192;
const TOKEN_SIGNATURE_HEX_BYTES: usize = 64;
const MAX_TOKEN_PAYLOAD_HEX_BYTES: usize = MAX_TOKEN_STRING_BYTES - TOKEN_SIGNATURE_HEX_BYTES - 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TokenScope {
Read,
Write,
}
impl TokenScope {
#[must_use]
pub const fn allows_read(self) -> bool {
matches!(self, Self::Read | Self::Write)
}
#[must_use]
pub const fn allows_write(self) -> bool {
matches!(self, Self::Write)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RepositoryProvider {
GitHub,
Gitea,
GitLab,
Codeberg,
Generic,
}
impl RepositoryProvider {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::GitHub => "github",
Self::Gitea => "gitea",
Self::GitLab => "gitlab",
Self::Codeberg => "codeberg",
Self::Generic => "generic",
}
}
}
#[derive(Debug, Clone, Copy, Error, PartialEq, Eq)]
#[error("repository provider was invalid")]
pub struct RepositoryProviderParseError;
impl FromStr for RepositoryProvider {
type Err = RepositoryProviderParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"github" => Ok(Self::GitHub),
"gitea" => Ok(Self::Gitea),
"gitlab" => Ok(Self::GitLab),
"codeberg" => Ok(Self::Codeberg),
"generic" => Ok(Self::Generic),
_other => Err(RepositoryProviderParseError),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryScope {
provider: RepositoryProvider,
owner: String,
name: String,
revision: Option<String>,
}
impl RepositoryScope {
pub fn new(
provider: RepositoryProvider,
owner: &str,
name: &str,
revision: Option<&str>,
) -> Result<Self, TokenClaimsError> {
validate_component(owner, TokenClaimsError::EmptyRepositoryOwner)?;
validate_component(name, TokenClaimsError::EmptyRepositoryName)?;
if let Some(value) = revision {
validate_component(value, TokenClaimsError::EmptyRevision)?;
}
Ok(Self {
provider,
owner: owner.to_owned(),
name: name.to_owned(),
revision: revision.map(ToOwned::to_owned),
})
}
#[must_use]
pub const fn provider(&self) -> RepositoryProvider {
self.provider
}
#[must_use]
pub fn owner(&self) -> &str {
&self.owner
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn revision(&self) -> Option<&str> {
self.revision.as_deref()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenClaims {
issuer: String,
subject: String,
scope: TokenScope,
repository: RepositoryScope,
expires_at_unix_seconds: u64,
}
impl TokenClaims {
pub fn new(
issuer: &str,
subject: &str,
scope: TokenScope,
repository: RepositoryScope,
expires_at_unix_seconds: u64,
) -> Result<Self, TokenClaimsError> {
validate_component(issuer, TokenClaimsError::EmptyIssuer)?;
validate_component(subject, TokenClaimsError::EmptySubject)?;
Ok(Self {
issuer: issuer.to_owned(),
subject: subject.to_owned(),
scope,
repository,
expires_at_unix_seconds,
})
}
#[must_use]
pub fn issuer(&self) -> &str {
&self.issuer
}
#[must_use]
pub fn subject(&self) -> &str {
&self.subject
}
#[must_use]
pub const fn scope(&self) -> TokenScope {
self.scope
}
#[must_use]
pub const fn repository(&self) -> &RepositoryScope {
&self.repository
}
#[must_use]
pub const fn expires_at_unix_seconds(&self) -> u64 {
self.expires_at_unix_seconds
}
}
#[derive(Debug, Clone, Copy, Error, PartialEq, Eq)]
pub enum TokenClaimsError {
#[error("token issuer must not be empty")]
EmptyIssuer,
#[error("token subject must not be empty")]
EmptySubject,
#[error("token repository owner must not be empty")]
EmptyRepositoryOwner,
#[error("token repository name must not be empty")]
EmptyRepositoryName,
#[error("token revision must not be empty when provided")]
EmptyRevision,
#[error("token components must not contain control characters")]
ControlCharacter,
#[error("token component exceeded supported length")]
TooLong,
}
const MIN_SIGNING_KEY_BYTES: usize = 32;
#[derive(Debug, Error)]
pub enum TokenCodecError {
#[error("token signing key must not be empty")]
EmptySigningKey,
#[error("token signing key must be at least {MIN_SIGNING_KEY_BYTES} bytes")]
SigningKeyTooShort,
#[error("token json operation failed")]
Json(#[from] JsonError),
#[error("token format was invalid")]
InvalidFormat,
#[error("token hex segment was invalid")]
InvalidHex(#[from] hex::FromHexError),
#[error("token signature was invalid")]
InvalidSignature,
#[error("token has expired")]
Expired,
#[error("token claims were invalid")]
Claims(#[from] TokenClaimsError),
}
#[derive(Clone)]
pub struct TokenSigner {
signing_key: SecretBytes,
}
impl fmt::Debug for TokenSigner {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("TokenSigner")
.field("signing_key", &"***")
.finish()
}
}
impl TokenSigner {
pub fn new(signing_key: &[u8]) -> Result<Self, TokenCodecError> {
if signing_key.is_empty() {
return Err(TokenCodecError::EmptySigningKey);
}
if signing_key.len() < MIN_SIGNING_KEY_BYTES {
return Err(TokenCodecError::SigningKeyTooShort);
}
Ok(Self {
signing_key: SecretBytes::from_slice(signing_key),
})
}
pub fn sign(&self, claims: &TokenClaims) -> Result<String, TokenCodecError> {
let payload = to_vec(claims)?;
let signature = self.signature(&payload)?;
Ok(format!(
"{}.{}",
hex::encode(payload),
hex::encode(signature)
))
}
pub fn verify_at(
&self,
token: &str,
current_unix_seconds: u64,
) -> Result<TokenClaims, TokenCodecError> {
if token.len() > MAX_TOKEN_STRING_BYTES {
return Err(TokenCodecError::InvalidFormat);
}
let Some((payload_hex, signature_hex)) = token.split_once('.') else {
return Err(TokenCodecError::InvalidFormat);
};
if payload_hex.is_empty()
|| payload_hex.len() > MAX_TOKEN_PAYLOAD_HEX_BYTES
|| signature_hex.len() != TOKEN_SIGNATURE_HEX_BYTES
{
return Err(TokenCodecError::InvalidFormat);
}
let payload = hex::decode(payload_hex)?;
let signature = hex::decode(signature_hex)?;
let expected_signature = self.signature(&payload)?;
if expected_signature.ct_eq(signature.as_slice()).unwrap_u8() != 1 {
return Err(TokenCodecError::InvalidSignature);
}
let claims = from_slice::<TokenClaims>(&payload)?;
validate_component(claims.issuer(), TokenClaimsError::EmptyIssuer)?;
validate_component(claims.subject(), TokenClaimsError::EmptySubject)?;
validate_component(
claims.repository().owner(),
TokenClaimsError::EmptyRepositoryOwner,
)?;
validate_component(
claims.repository().name(),
TokenClaimsError::EmptyRepositoryName,
)?;
if let Some(revision) = claims.repository().revision() {
validate_component(revision, TokenClaimsError::EmptyRevision)?;
}
if claims.expires_at_unix_seconds() < current_unix_seconds {
return Err(TokenCodecError::Expired);
}
Ok(claims)
}
pub fn verify_now(&self, token: &str) -> Result<TokenClaims, TokenCodecError> {
self.verify_at(token, unix_now_seconds_lossy())
}
fn signature(&self, payload: &[u8]) -> Result<Vec<u8>, TokenCodecError> {
let mut mac = TokenMac::new_from_slice(self.signing_key.expose_secret())
.map_err(|_error| TokenCodecError::EmptySigningKey)?;
mac.update(payload);
Ok(mac.finalize().into_bytes().to_vec())
}
}
fn validate_component(value: &str, empty_error: TokenClaimsError) -> Result<(), TokenClaimsError> {
if value.trim().is_empty() {
return Err(empty_error);
}
if value.len() > MAX_TOKEN_COMPONENT_BYTES {
return Err(TokenClaimsError::TooLong);
}
if value.chars().any(char::is_control) {
return Err(TokenClaimsError::ControlCharacter);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
MAX_TOKEN_COMPONENT_BYTES, MAX_TOKEN_PAYLOAD_HEX_BYTES, RepositoryProvider,
RepositoryProviderParseError, RepositoryScope, TOKEN_SIGNATURE_HEX_BYTES, TokenClaims,
TokenClaimsError, TokenCodecError, TokenScope, TokenSigner,
};
#[test]
fn write_token_allows_read_and_write() {
assert!(TokenScope::Write.allows_read());
assert!(TokenScope::Write.allows_write());
}
#[test]
fn read_token_does_not_allow_write() {
assert!(TokenScope::Read.allows_read());
assert!(!TokenScope::Read.allows_write());
}
#[test]
fn repository_provider_parses_stable_names() {
assert_eq!("github".parse(), Ok(RepositoryProvider::GitHub));
assert_eq!("gitea".parse(), Ok(RepositoryProvider::Gitea));
assert_eq!("gitlab".parse(), Ok(RepositoryProvider::GitLab));
assert_eq!("codeberg".parse(), Ok(RepositoryProvider::Codeberg));
assert_eq!("generic".parse(), Ok(RepositoryProvider::Generic));
assert_eq!(
"bitbucket".parse::<RepositoryProvider>(),
Err(RepositoryProviderParseError)
);
}
#[test]
fn token_signer_debug_redacts_signing_key_material() {
let signer = TokenSigner::new(&[1; 32]);
assert!(signer.is_ok());
let Ok(signer) = signer else {
return;
};
let rendered = format!("{signer:?}");
assert!(!rendered.contains("[1, 2, 3, 4]"));
assert!(rendered.contains("***"));
}
#[test]
fn token_claims_reject_empty_subject() {
let repository =
RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"));
assert!(repository.is_ok());
let Ok(repository) = repository else {
return;
};
let claims = TokenClaims::new("issuer", " ", TokenScope::Read, repository, 42);
assert_eq!(claims, Err(TokenClaimsError::EmptySubject));
}
#[test]
fn repository_scope_rejects_empty_owner() {
let scope = RepositoryScope::new(RepositoryProvider::GitHub, "", "assets", Some("main"));
assert_eq!(scope, Err(TokenClaimsError::EmptyRepositoryOwner));
}
#[test]
fn repository_scope_rejects_oversized_components() {
let oversized = "o".repeat(MAX_TOKEN_COMPONENT_BYTES + 1);
let scope = RepositoryScope::new(RepositoryProvider::GitHub, &oversized, "assets", None);
assert_eq!(scope, Err(TokenClaimsError::TooLong));
}
#[test]
fn token_claims_reject_oversized_subject() {
let repository =
RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"));
assert!(repository.is_ok());
let Ok(repository) = repository else {
return;
};
let oversized = "s".repeat(MAX_TOKEN_COMPONENT_BYTES + 1);
let claims = TokenClaims::new("issuer", &oversized, TokenScope::Read, repository, 42);
assert_eq!(claims, Err(TokenClaimsError::TooLong));
}
#[test]
fn token_roundtrips_through_sign_and_verify() {
let signer = TokenSigner::new(b"test-signing-key-32-bytes-long!!");
assert!(signer.is_ok());
let Ok(signer) = signer else {
return;
};
let repository =
RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"));
assert!(repository.is_ok());
let Ok(repository) = repository else {
return;
};
let claims = TokenClaims::new(
"issuer",
"provider-user-1",
TokenScope::Write,
repository,
120,
);
assert!(claims.is_ok());
let Ok(claims) = claims else {
return;
};
let token = signer.sign(&claims);
assert!(token.is_ok());
let Ok(token) = token else {
return;
};
let verified = signer.verify_at(&token, 119);
assert!(verified.is_ok());
let Ok(verified) = verified else {
return;
};
assert_eq!(verified, claims);
}
#[test]
fn token_verify_rejects_tampering() {
let signer = TokenSigner::new(b"test-signing-key-32-bytes-long!!");
assert!(signer.is_ok());
let Ok(signer) = signer else {
return;
};
let repository =
RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"));
assert!(repository.is_ok());
let Ok(repository) = repository else {
return;
};
let claims = TokenClaims::new(
"issuer",
"provider-user-1",
TokenScope::Read,
repository,
120,
);
assert!(claims.is_ok());
let Ok(claims) = claims else {
return;
};
let token = signer.sign(&claims);
assert!(token.is_ok());
let Ok(token) = token else {
return;
};
let Some((payload, _signature)) = token.split_once('.') else {
return;
};
let tampered = format!("{payload}.{}", "00".repeat(32));
assert!(matches!(
signer.verify_at(&tampered, 119),
Err(TokenCodecError::InvalidSignature)
));
}
#[test]
fn token_verify_rejects_oversized_token_before_hex_decoding() {
let signer = TokenSigner::new(b"test-signing-key-32-bytes-long!!");
assert!(signer.is_ok());
let Ok(signer) = signer else {
return;
};
let token = format!(
"{}.{}",
"a".repeat(MAX_TOKEN_PAYLOAD_HEX_BYTES + 1),
"0".repeat(TOKEN_SIGNATURE_HEX_BYTES)
);
assert!(matches!(
signer.verify_at(&token, 119),
Err(TokenCodecError::InvalidFormat)
));
}
#[test]
fn token_verify_rejects_oversized_signature_before_hex_decoding() {
let signer = TokenSigner::new(b"test-signing-key-32-bytes-long!!");
assert!(signer.is_ok());
let Ok(signer) = signer else {
return;
};
let token = format!("{}.{}", "7b7d", "0".repeat(TOKEN_SIGNATURE_HEX_BYTES + 1));
assert!(matches!(
signer.verify_at(&token, 119),
Err(TokenCodecError::InvalidFormat)
));
}
#[test]
fn token_verify_rejects_expired_tokens() {
let signer = TokenSigner::new(b"test-signing-key-32-bytes-long!!");
assert!(signer.is_ok());
let Ok(signer) = signer else {
return;
};
let repository =
RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"));
assert!(repository.is_ok());
let Ok(repository) = repository else {
return;
};
let claims = TokenClaims::new(
"issuer",
"provider-user-1",
TokenScope::Read,
repository,
120,
);
assert!(claims.is_ok());
let Ok(claims) = claims else {
return;
};
let token = signer.sign(&claims);
assert!(token.is_ok());
let Ok(token) = token else {
return;
};
assert!(matches!(
signer.verify_at(&token, 121),
Err(TokenCodecError::Expired)
));
}
}