use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct LicenseClaims {
pub sub: String,
pub tier: String,
pub iat: u64,
pub exp: u64,
}
#[derive(Debug, thiserror::Error)]
pub enum NookLicenseError {
#[error("[license] invalid token format: {msg}")]
InvalidFormat { msg: String },
#[error("[license] invalid signature")]
InvalidSignature,
#[error("[license] token expired at {exp}, current time {now}")]
Expired { exp: u64, now: u64 },
#[error("[license] malformed claims: {msg}")]
MalformedClaims { msg: String },
}
pub fn verify(
token: &str,
public_key: &[u8; 32],
now_unix_seconds: u64,
) -> Result<LicenseClaims, NookLicenseError> {
use base64ct::{Base64UrlUnpadded, Encoding};
use ed25519_dalek::{Signature, VerifyingKey};
let (payload_b64, sig_b64) =
token
.split_once('.')
.ok_or_else(|| NookLicenseError::InvalidFormat {
msg: "expected <payload>.<sig>".to_string(),
})?;
if payload_b64.is_empty() || sig_b64.is_empty() {
return Err(NookLicenseError::InvalidFormat {
msg: "empty payload or signature segment".to_string(),
});
}
let mut sig_bytes = [0_u8; 64];
Base64UrlUnpadded::decode(sig_b64, &mut sig_bytes).map_err(|e| {
NookLicenseError::InvalidFormat {
msg: format!("signature b64 decode: {e}"),
}
})?;
let signature = Signature::from_bytes(&sig_bytes);
let vk = VerifyingKey::from_bytes(public_key).map_err(|_| NookLicenseError::InvalidFormat {
msg: "public key not on curve".to_string(),
})?;
vk.verify_strict(payload_b64.as_bytes(), &signature)
.map_err(|_| NookLicenseError::InvalidSignature)?;
let payload_bytes = Base64UrlUnpadded::decode_vec(payload_b64).map_err(|e| {
NookLicenseError::InvalidFormat {
msg: format!("payload b64 decode: {e}"),
}
})?;
let claims: LicenseClaims = serde_json::from_slice(&payload_bytes)
.map_err(|e| NookLicenseError::MalformedClaims { msg: e.to_string() })?;
if claims.exp < now_unix_seconds {
return Err(NookLicenseError::Expired {
exp: claims.exp,
now: now_unix_seconds,
});
}
Ok(claims)
}
#[cfg(test)]
mod tests {
use super::*;
use base64ct::{Base64UrlUnpadded, Encoding};
use ed25519_dalek::{Signer, SigningKey};
use rand_core::{OsRng, RngCore};
fn fresh_signing_key() -> SigningKey {
let mut secret = [0_u8; 32];
OsRng.fill_bytes(&mut secret);
SigningKey::from_bytes(&secret)
}
fn make_token(claims: &serde_json::Value, sk: &SigningKey) -> String {
let payload_json = serde_json::to_vec(claims).unwrap();
let payload_b64 = Base64UrlUnpadded::encode_string(&payload_json);
let signature = sk.sign(payload_b64.as_bytes());
let sig_b64 = Base64UrlUnpadded::encode_string(&signature.to_bytes());
format!("{payload_b64}.{sig_b64}")
}
#[test]
fn valid_token_verifies_with_correct_key() {
let sk = fresh_signing_key();
let pk = sk.verifying_key().to_bytes();
let token = make_token(
&serde_json::json!({
"sub": "test-customer", "tier": "team",
"iat": 1_700_000_000_u64, "exp": 9_999_999_999_u64,
}),
&sk,
);
let claims = verify(&token, &pk, 1_700_000_001).unwrap();
assert_eq!(claims.sub, "test-customer");
assert_eq!(claims.tier, "team");
assert_eq!(claims.iat, 1_700_000_000);
assert_eq!(claims.exp, 9_999_999_999);
}
#[test]
fn tampered_signature_fails() {
let sk = fresh_signing_key();
let pk = sk.verifying_key().to_bytes();
let mut token = make_token(
&serde_json::json!({
"sub": "x", "tier": "team",
"iat": 1_700_000_000_u64, "exp": 9_999_999_999_u64,
}),
&sk,
);
let last = token.pop().unwrap();
token.push(if last == 'A' { 'B' } else { 'A' });
let err = verify(&token, &pk, 1_700_000_001).unwrap_err();
assert!(
matches!(
err,
NookLicenseError::InvalidSignature | NookLicenseError::InvalidFormat { .. }
),
"expected InvalidSignature or InvalidFormat, got {err:?}",
);
}
#[test]
fn expired_token_fails() {
let sk = fresh_signing_key();
let pk = sk.verifying_key().to_bytes();
let token = make_token(
&serde_json::json!({
"sub": "x", "tier": "team",
"iat": 1_700_000_000_u64, "exp": 1_700_000_100_u64,
}),
&sk,
);
let err = verify(&token, &pk, 1_700_000_200).unwrap_err();
match err {
NookLicenseError::Expired { exp, now } => {
assert_eq!(exp, 1_700_000_100);
assert_eq!(now, 1_700_000_200);
}
other => panic!("expected Expired, got {other:?}"),
}
}
#[test]
fn malformed_payload_fails_after_signature_ok() {
let sk = fresh_signing_key();
let pk = sk.verifying_key().to_bytes();
let payload_b64 = Base64UrlUnpadded::encode_string(b"{\"garbage\":true}");
let sig = sk.sign(payload_b64.as_bytes());
let sig_b64 = Base64UrlUnpadded::encode_string(&sig.to_bytes());
let token = format!("{payload_b64}.{sig_b64}");
let err = verify(&token, &pk, 1_700_000_001).unwrap_err();
assert!(
matches!(err, NookLicenseError::MalformedClaims { .. }),
"expected MalformedClaims, got {err:?}",
);
}
#[test]
fn invalid_format_fails() {
let pk_zero = [0_u8; 32];
let err = verify("not-a-jwt", &pk_zero, 0).unwrap_err();
assert!(
matches!(err, NookLicenseError::InvalidFormat { .. }),
"no-dot: expected InvalidFormat, got {err:?}",
);
let err = verify(".validsig", &pk_zero, 0).unwrap_err();
assert!(
matches!(err, NookLicenseError::InvalidFormat { .. }),
"empty-payload: expected InvalidFormat, got {err:?}",
);
let err = verify("payload.", &pk_zero, 0).unwrap_err();
assert!(
matches!(err, NookLicenseError::InvalidFormat { .. }),
"empty-sig: expected InvalidFormat, got {err:?}",
);
let err = verify("aGVsbG8.!!!!", &pk_zero, 0).unwrap_err();
assert!(
matches!(err, NookLicenseError::InvalidFormat { .. }),
"bad-b64-sig: expected InvalidFormat, got {err:?}",
);
let sk = fresh_signing_key();
let token = make_token(
&serde_json::json!({
"sub": "x", "tier": "team",
"iat": 1_700_000_000_u64, "exp": 9_999_999_999_u64,
}),
&sk,
);
let pk_bad = [0xFF_u8; 32];
let err = verify(&token, &pk_bad, 1_700_000_001).unwrap_err();
assert!(
matches!(
err,
NookLicenseError::InvalidFormat { .. } | NookLicenseError::InvalidSignature
),
"non-curve-pk: expected InvalidFormat or InvalidSignature, got {err:?}",
);
}
}