use std::time::{SystemTime, UNIX_EPOCH};
use base64::Engine;
use ed25519_dalek::SigningKey;
use hkdf::Hkdf;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use uuid::Uuid;
use zeroize::Zeroizing;
use crate::error::AppError;
const B64: base64::engine::general_purpose::GeneralPurpose =
base64::engine::general_purpose::URL_SAFE_NO_PAD;
pub const INSTALL_AUDIENCE: &str = "vtc-install";
pub const INSTALL_SUBJECT: &str = "install";
pub const INSTALL_TOKEN_DEFAULT_TTL_SECS: u64 = 15 * 60;
pub const INSTALL_SESSION_AUDIENCE: &str = "vtc-install-session";
pub const INSTALL_SESSION_DEFAULT_TTL_SECS: u64 = 5 * 60;
const HKDF_INFO: &[u8] = b"vtc-install-jwt-key/v2";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstallTokenClaims {
pub iss: String,
pub sub: String,
pub aud: String,
pub exp: u64,
pub iat: u64,
pub jti: String,
pub cnonce: String,
pub epubkey: String,
pub admin_did: String,
}
pub struct InstallTokenSigner {
encoding: EncodingKey,
decoding: DecodingKey,
}
impl InstallTokenSigner {
pub fn from_master_seed(master_seed: &[u8]) -> Result<Self, AppError> {
let mut signing_key_bytes = Zeroizing::new([0u8; 32]);
Hkdf::<Sha256>::new(None, master_seed)
.expand(HKDF_INFO, signing_key_bytes.as_mut())
.map_err(|e| AppError::Internal(format!("HKDF expand failed: {e}")))?;
let signing_key = SigningKey::from_bytes(&signing_key_bytes);
let public_bytes = signing_key.verifying_key().to_bytes();
let mut pkcs8 = Vec::with_capacity(48);
pkcs8.extend_from_slice(&[
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22,
0x04, 0x20,
]);
pkcs8.extend_from_slice(signing_key_bytes.as_ref());
let encoding = EncodingKey::from_ed_der(&pkcs8);
let decoding = DecodingKey::from_ed_der(&public_bytes);
Ok(Self { encoding, decoding })
}
pub fn encode(&self, claims: &InstallTokenClaims) -> Result<String, AppError> {
let header = Header::new(Algorithm::EdDSA);
jsonwebtoken::encode(&header, claims, &self.encoding)
.map_err(|e| AppError::Internal(format!("install JWT encode failed: {e}")))
}
pub fn decode(&self, token: &str) -> Result<InstallTokenClaims, AppError> {
let mut validation = Validation::new(Algorithm::EdDSA);
validation.set_audience(&[INSTALL_AUDIENCE]);
validation.set_required_spec_claims(&["exp", "sub", "aud", "iat", "iss"]);
let claims = jsonwebtoken::decode::<InstallTokenClaims>(token, &self.decoding, &validation)
.map(|data| data.claims)
.map_err(|_| AppError::Unauthorized("invalid install token".into()))?;
if claims.sub != INSTALL_SUBJECT {
return Err(AppError::Unauthorized("invalid install token".into()));
}
Ok(claims)
}
pub fn encode_session(&self, claims: &InstallSessionClaims) -> Result<String, AppError> {
let header = Header::new(Algorithm::EdDSA);
jsonwebtoken::encode(&header, claims, &self.encoding)
.map_err(|e| AppError::Internal(format!("install session JWT encode failed: {e}")))
}
pub fn decode_session(&self, token: &str) -> Result<InstallSessionClaims, AppError> {
let mut validation = Validation::new(Algorithm::EdDSA);
validation.set_audience(&[INSTALL_SESSION_AUDIENCE]);
validation.set_required_spec_claims(&["exp", "sub", "aud", "iat", "iss"]);
jsonwebtoken::decode::<InstallSessionClaims>(token, &self.decoding, &validation)
.map(|data| data.claims)
.map_err(|_| AppError::Unauthorized("invalid install session token".into()))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstallSessionClaims {
pub iss: String,
pub sub: String,
pub aud: String,
pub exp: u64,
pub iat: u64,
pub jti: String,
pub install_jti: String,
}
#[derive(Debug)]
pub struct MintedInstallToken {
pub jwt: String,
pub jti: Uuid,
pub claims: InstallTokenClaims,
pub cnonce_bytes: [u8; 32],
pub ephemeral_signing_key: Zeroizing<[u8; 32]>,
pub expires_at_unix: u64,
}
pub fn mint_install_token(
signer: &InstallTokenSigner,
issuer_did: &str,
admin_did: &str,
ttl_seconds: u64,
) -> Result<MintedInstallToken, AppError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let exp = now + ttl_seconds;
let jti = Uuid::new_v4();
let mut cnonce_bytes = [0u8; 32];
rand::fill(&mut cnonce_bytes);
let cnonce = B64.encode(cnonce_bytes);
let mut ephemeral_bytes = Zeroizing::new([0u8; 32]);
rand::fill(&mut *ephemeral_bytes);
let ephemeral_signing_key = SigningKey::from_bytes(&ephemeral_bytes);
let epubkey = B64.encode(ephemeral_signing_key.verifying_key().to_bytes());
let claims = InstallTokenClaims {
iss: issuer_did.to_string(),
sub: INSTALL_SUBJECT.to_string(),
aud: INSTALL_AUDIENCE.to_string(),
exp,
iat: now,
jti: jti.to_string(),
cnonce,
epubkey,
admin_did: admin_did.to_string(),
};
let jwt = signer.encode(&claims)?;
Ok(MintedInstallToken {
jwt,
jti,
claims,
cnonce_bytes,
ephemeral_signing_key: ephemeral_bytes,
expires_at_unix: exp,
})
}
pub fn parse_install_token(
signer: &InstallTokenSigner,
token: &str,
) -> Result<InstallTokenClaims, AppError> {
signer.decode(token)
}
pub fn mint_install_session_token(
signer: &InstallTokenSigner,
issuer_did: &str,
admin_did: &str,
install_jti: &str,
ttl_seconds: u64,
) -> Result<String, AppError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let exp = now + ttl_seconds;
let jti = Uuid::new_v4().to_string();
let claims = InstallSessionClaims {
iss: issuer_did.to_string(),
sub: admin_did.to_string(),
aud: INSTALL_SESSION_AUDIENCE.to_string(),
exp,
iat: now,
jti,
install_jti: install_jti.to_string(),
};
signer.encode_session(&claims)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Once;
fn init_jwt_provider() {
static INIT: Once = Once::new();
INIT.call_once(|| {
let _ = jsonwebtoken::crypto::aws_lc::DEFAULT_PROVIDER.install_default();
});
}
const SEED: [u8; 32] = [0xAB; 32];
fn signer() -> InstallTokenSigner {
init_jwt_provider();
InstallTokenSigner::from_master_seed(&SEED).unwrap()
}
#[test]
fn round_trip_returns_same_claims() {
let signer = signer();
let minted = mint_install_token(
&signer,
"did:webvh:vtc.example.com:abc",
"did:key:zAdmin",
600,
)
.unwrap();
let back = parse_install_token(&signer, &minted.jwt).unwrap();
assert_eq!(back.iss, "did:webvh:vtc.example.com:abc");
assert_eq!(back.aud, INSTALL_AUDIENCE);
assert_eq!(back.sub, INSTALL_SUBJECT);
assert_eq!(back.jti, minted.jti.to_string());
assert_eq!(back.cnonce, minted.claims.cnonce);
assert_eq!(back.epubkey, minted.claims.epubkey);
}
#[test]
fn different_seeds_produce_disjoint_keys() {
init_jwt_provider();
let a = InstallTokenSigner::from_master_seed(&[0x01; 32]).unwrap();
let b = InstallTokenSigner::from_master_seed(&[0x02; 32]).unwrap();
let minted = mint_install_token(&a, "did:webvh:x", "did:key:zAdmin", 60).unwrap();
let err = parse_install_token(&b, &minted.jwt).expect_err("must reject");
assert!(matches!(err, AppError::Unauthorized(_)));
}
#[test]
fn expired_token_is_rejected() {
let signer = signer();
let minted = mint_install_token(&signer, "did:webvh:x", "did:key:zAdmin", 0).unwrap();
let claims = InstallTokenClaims {
exp: 1, iat: 0,
..minted.claims.clone()
};
let stale = signer.encode(&claims).unwrap();
let err = parse_install_token(&signer, &stale).expect_err("expired");
assert!(matches!(err, AppError::Unauthorized(_)));
}
#[test]
fn wrong_audience_is_rejected() {
let signer = signer();
let minted = mint_install_token(&signer, "did:webvh:x", "did:key:zAdmin", 600).unwrap();
let mut claims = minted.claims.clone();
claims.aud = "VTC".to_string();
let stale = signer.encode(&claims).unwrap();
let err = parse_install_token(&signer, &stale).expect_err("wrong aud");
assert!(matches!(err, AppError::Unauthorized(_)));
}
#[test]
fn wrong_subject_is_rejected() {
let signer = signer();
let minted = mint_install_token(&signer, "did:webvh:x", "did:key:zAdmin", 600).unwrap();
let mut claims = minted.claims.clone();
claims.sub = "session".to_string();
let stale = signer.encode(&claims).unwrap();
let err = parse_install_token(&signer, &stale).expect_err("wrong sub");
assert!(matches!(err, AppError::Unauthorized(_)));
}
#[test]
fn tampered_signature_is_rejected() {
let signer = signer();
let minted = mint_install_token(&signer, "did:webvh:x", "did:key:zAdmin", 600).unwrap();
let mut bytes = minted.jwt.into_bytes();
let last = bytes.len() - 1;
bytes[last] = if bytes[last] == b'A' { b'B' } else { b'A' };
let tampered = String::from_utf8(bytes).unwrap();
let err = parse_install_token(&signer, &tampered).expect_err("tampered");
assert!(matches!(err, AppError::Unauthorized(_)));
}
#[test]
fn cnonce_is_32_bytes_base64url() {
let signer = signer();
let minted = mint_install_token(&signer, "did:webvh:x", "did:key:zAdmin", 600).unwrap();
let decoded = B64.decode(&minted.claims.cnonce).unwrap();
assert_eq!(decoded.len(), 32);
assert_eq!(decoded.as_slice(), &minted.cnonce_bytes[..]);
}
#[test]
fn epubkey_is_32_bytes_base64url() {
let signer = signer();
let minted = mint_install_token(&signer, "did:webvh:x", "did:key:zAdmin", 600).unwrap();
let decoded = B64.decode(&minted.claims.epubkey).unwrap();
assert_eq!(decoded.len(), 32);
}
}