use chrono::{DateTime, Utc};
use pubky_common::{
auth::{
jws::{GrantId, PopNonce, POP_JWS_TYP},
pop::PopProofClaims,
},
crypto::PublicKey,
};
use super::jws_crypto::{self, JwsCompact};
pub const POP_MAX_AGE_SECS: u64 = 180;
pub const POP_NONCE_GC_THRESHOLD_SECS: u64 = 2 * POP_MAX_AGE_SECS;
#[derive(Clone, Debug)]
pub struct PopProof {
#[allow(dead_code)]
pub grant_id: GrantId,
pub nonce: PopNonce,
#[allow(dead_code)]
pub issued_at: DateTime<Utc>,
}
pub struct PopVerificationContext<'a> {
pub cnf_key: &'a PublicKey,
pub expected_audience: &'a str,
pub expected_grant_id: &'a GrantId,
}
impl PopProof {
pub fn verify(compact: &JwsCompact, context: &PopVerificationContext) -> Result<Self, Error> {
let raw = verify_signature(compact.as_str(), context.cnf_key)?;
check_header_type(compact.as_str())?;
check_audience(&raw, context.expected_audience)?;
check_grant_binding(&raw, context.expected_grant_id)?;
check_timestamp(&raw)?;
parse_verified_pop(raw)
}
}
fn check_header_type(compact: &str) -> Result<(), Error> {
let header = jsonwebtoken::decode_header(compact).map_err(|_| Error::InvalidFormat)?;
match header.typ.as_deref() {
Some(POP_JWS_TYP) => Ok(()),
_ => Err(Error::InvalidHeaderType),
}
}
fn verify_signature(compact: &str, cnf_key: &PublicKey) -> Result<PopProofClaims, Error> {
let decoding_key = jws_crypto::decoding_key(cnf_key);
let validation = jws_crypto::eddsa_validation();
let token_data = jsonwebtoken::decode::<PopProofClaims>(compact, &decoding_key, &validation)
.map_err(|_| Error::InvalidSignature)?;
Ok(token_data.claims)
}
fn check_audience(raw: &PopProofClaims, expected: &str) -> Result<(), Error> {
if raw.aud.z32() != expected {
return Err(Error::AudienceMismatch);
}
Ok(())
}
fn check_grant_binding(raw: &PopProofClaims, expected: &GrantId) -> Result<(), Error> {
if raw.gid != *expected {
return Err(Error::GrantIdMismatch);
}
Ok(())
}
fn check_timestamp(raw: &PopProofClaims) -> Result<(), Error> {
let now = Utc::now().timestamp() as u64;
if now.abs_diff(raw.iat) > POP_MAX_AGE_SECS {
return Err(Error::TimestampOutOfRange);
}
Ok(())
}
fn parse_verified_pop(raw: PopProofClaims) -> Result<PopProof, Error> {
let issued_at = DateTime::from_timestamp(raw.iat as i64, 0).ok_or(Error::InvalidTimestamp)?;
Ok(PopProof {
grant_id: raw.gid,
nonce: raw.nonce,
issued_at,
})
}
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("invalid PoP format")]
InvalidFormat,
#[error("invalid PoP header type, expected pubky-pop")]
InvalidHeaderType,
#[error("invalid PoP signature")]
InvalidSignature,
#[error("PoP audience mismatch")]
AudienceMismatch,
#[error("PoP grant ID mismatch")]
GrantIdMismatch,
#[error("PoP timestamp out of range")]
TimestampOutOfRange,
#[error("invalid timestamp in PoP proof")]
InvalidTimestamp,
}
#[cfg(test)]
mod tests {
use pubky_common::crypto::Keypair;
use super::jws_crypto;
use super::*;
fn sign_pop(client_kp: &Keypair, raw: &PopProofClaims) -> JwsCompact {
let header = jws_crypto::eddsa_header(POP_JWS_TYP);
let enc = jws_crypto::encoding_key(client_kp);
let token = jsonwebtoken::encode(&header, raw, &enc).unwrap();
JwsCompact::parse(&token).unwrap()
}
fn make_valid_pop(hs_kp: &Keypair) -> PopProofClaims {
PopProofClaims {
aud: hs_kp.public_key(),
gid: GrantId::generate(),
nonce: PopNonce::generate(),
iat: Utc::now().timestamp() as u64,
}
}
#[test]
fn verify_accepts_pubky_common_sign_jws() {
let client_kp = Keypair::random();
let hs_kp = Keypair::random();
let raw = make_valid_pop(&hs_kp);
let compact_str = pubky_common::auth::jws::sign_jws(&client_kp, POP_JWS_TYP, &raw);
let compact = JwsCompact::parse(&compact_str).unwrap();
let cnf_key = client_kp.public_key();
let aud = hs_kp.public_key().z32();
let context = PopVerificationContext {
cnf_key: &cnf_key,
expected_audience: &aud,
expected_grant_id: &raw.gid,
};
let pop = PopProof::verify(&compact, &context).unwrap();
assert_eq!(pop.grant_id, raw.gid);
}
#[test]
fn sign_and_verify_roundtrip() {
let client_kp = Keypair::random();
let hs_kp = Keypair::random();
let raw = make_valid_pop(&hs_kp);
let compact = sign_pop(&client_kp, &raw);
let cnf_key = client_kp.public_key();
let aud = hs_kp.public_key().z32();
let context = PopVerificationContext {
cnf_key: &cnf_key,
expected_audience: &aud,
expected_grant_id: &raw.gid,
};
let pop = PopProof::verify(&compact, &context).unwrap();
assert_eq!(pop.grant_id, raw.gid);
assert_eq!(pop.nonce, raw.nonce);
}
#[test]
fn reject_wrong_cnf_key() {
let client_kp = Keypair::random();
let wrong_kp = Keypair::random();
let hs_kp = Keypair::random();
let raw = make_valid_pop(&hs_kp);
let compact = sign_pop(&client_kp, &raw);
let wrong_pk = wrong_kp.public_key();
let aud = hs_kp.public_key().z32();
let context = PopVerificationContext {
cnf_key: &wrong_pk,
expected_audience: &aud,
expected_grant_id: &raw.gid,
};
let result = PopProof::verify(&compact, &context);
assert!(matches!(result, Err(Error::InvalidSignature)));
}
#[test]
fn reject_wrong_audience() {
let client_kp = Keypair::random();
let hs_kp = Keypair::random();
let raw = make_valid_pop(&hs_kp);
let compact = sign_pop(&client_kp, &raw);
let cnf_key = client_kp.public_key();
let context = PopVerificationContext {
cnf_key: &cnf_key,
expected_audience: "wrong-audience",
expected_grant_id: &raw.gid,
};
let result = PopProof::verify(&compact, &context);
assert!(matches!(result, Err(Error::AudienceMismatch)));
}
#[test]
fn reject_wrong_grant_id() {
let client_kp = Keypair::random();
let hs_kp = Keypair::random();
let raw = make_valid_pop(&hs_kp);
let compact = sign_pop(&client_kp, &raw);
let cnf_key = client_kp.public_key();
let aud = hs_kp.public_key().z32();
let wrong_gid = GrantId::generate();
let context = PopVerificationContext {
cnf_key: &cnf_key,
expected_audience: &aud,
expected_grant_id: &wrong_gid,
};
let result = PopProof::verify(&compact, &context);
assert!(matches!(result, Err(Error::GrantIdMismatch)));
}
#[test]
fn reject_wrong_header_type() {
let client_kp = Keypair::random();
let hs_kp = Keypair::random();
let raw = make_valid_pop(&hs_kp);
let header = jws_crypto::eddsa_header("wrong-typ");
let enc = jws_crypto::encoding_key(&client_kp);
let compact =
JwsCompact::parse(&jsonwebtoken::encode(&header, &raw, &enc).unwrap()).unwrap();
let cnf_key = client_kp.public_key();
let aud = hs_kp.public_key().z32();
let context = PopVerificationContext {
cnf_key: &cnf_key,
expected_audience: &aud,
expected_grant_id: &raw.gid,
};
let result = PopProof::verify(&compact, &context);
assert!(matches!(result, Err(Error::InvalidHeaderType)));
}
#[test]
fn reject_stale_timestamp() {
let client_kp = Keypair::random();
let hs_kp = Keypair::random();
let mut raw = make_valid_pop(&hs_kp);
raw.iat = 1000; let compact = sign_pop(&client_kp, &raw);
let cnf_key = client_kp.public_key();
let aud = hs_kp.public_key().z32();
let context = PopVerificationContext {
cnf_key: &cnf_key,
expected_audience: &aud,
expected_grant_id: &raw.gid,
};
let result = PopProof::verify(&compact, &context);
assert!(matches!(result, Err(Error::TimestampOutOfRange)));
}
}