use chrono::Utc;
use super::sign;
use crate::error::JoyError;
use crate::model::project::{Attestation, AttestationSignedFields, Member, MemberCapabilities};
pub fn sign_attestation(
attester_email: &str,
attester_keypair: &sign::IdentityKeypair,
signed_fields: AttestationSignedFields,
) -> Attestation {
let bytes = signed_fields.canonical_bytes();
let signature = attester_keypair.sign(&bytes);
Attestation {
attester: attester_email.to_string(),
signed_fields,
signed_at: Utc::now(),
signature: hex::encode(signature),
}
}
pub fn signed_fields_for(
email: &str,
capabilities: &MemberCapabilities,
enrollment_verifier: Option<&str>,
) -> AttestationSignedFields {
AttestationSignedFields {
email: email.to_string(),
capabilities: capabilities.clone(),
enrollment_verifier: enrollment_verifier.map(|s| s.to_string()),
}
}
pub fn verify_attestation(
attestation: &Attestation,
attester_public_key: &sign::PublicKey,
member_email: &str,
member: &Member,
) -> Result<(), JoyError> {
let sig_bytes = hex::decode(&attestation.signature)
.map_err(|e| JoyError::AuthFailed(format!("attestation signature is not hex: {e}")))?;
let canonical = attestation.signed_fields.canonical_bytes();
attester_public_key
.verify(&canonical, &sig_bytes)
.map_err(|_| JoyError::AuthFailed("attestation signature does not verify".into()))?;
if attestation.signed_fields.email != member_email {
return Err(JoyError::AuthFailed(
"attestation email does not match member".into(),
));
}
if attestation.signed_fields.capabilities != member.capabilities {
return Err(JoyError::AuthFailed(
"attestation capabilities do not match member".into(),
));
}
if let Some(current) = &member.enrollment_verifier {
if attestation.signed_fields.enrollment_verifier.as_deref() != Some(current.as_str()) {
return Err(JoyError::AuthFailed(
"attestation enrollment_verifier does not match member".into(),
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::project::{CapabilityConfig, MemberCapabilities};
fn make_kp() -> sign::IdentityKeypair {
sign::IdentityKeypair::from_random()
}
fn fresh_member(caps: MemberCapabilities, otp: Option<String>) -> Member {
let mut m = Member::new(caps);
m.enrollment_verifier = otp;
m
}
#[test]
fn sign_and_verify_roundtrip() {
let kp = make_kp();
let pk = kp.public_key();
let fields = signed_fields_for(
"alice@example.com",
&MemberCapabilities::All,
Some("deadbeef"),
);
let att = sign_attestation("horst@example.com", &kp, fields);
let member = fresh_member(MemberCapabilities::All, Some("deadbeef".into()));
verify_attestation(&att, &pk, "alice@example.com", &member).unwrap();
}
#[test]
fn verify_fails_on_tampered_capability() {
let kp = make_kp();
let pk = kp.public_key();
let fields = signed_fields_for("alice@example.com", &MemberCapabilities::All, None);
let att = sign_attestation("horst@example.com", &kp, fields);
let mut caps = std::collections::BTreeMap::new();
caps.insert(
crate::model::item::Capability::Implement,
CapabilityConfig::default(),
);
let member = fresh_member(MemberCapabilities::Specific(caps), None);
let err = verify_attestation(&att, &pk, "alice@example.com", &member).unwrap_err();
assert!(matches!(err, JoyError::AuthFailed(msg) if msg.contains("capabilities")));
}
#[test]
fn verify_fails_on_tampered_signature() {
let kp = make_kp();
let pk = kp.public_key();
let fields = signed_fields_for("alice@example.com", &MemberCapabilities::All, None);
let mut att = sign_attestation("horst@example.com", &kp, fields);
let mut bytes: Vec<char> = att.signature.chars().collect();
bytes[0] = if bytes[0] == '0' { '1' } else { '0' };
att.signature = bytes.into_iter().collect();
let member = fresh_member(MemberCapabilities::All, None);
let err = verify_attestation(&att, &pk, "alice@example.com", &member).unwrap_err();
assert!(matches!(err, JoyError::AuthFailed(msg) if msg.contains("signature")));
}
#[test]
fn verify_accepts_cleared_enrollment_verifier_post_redemption() {
let kp = make_kp();
let pk = kp.public_key();
let fields = signed_fields_for("alice@example.com", &MemberCapabilities::All, Some("abcd"));
let att = sign_attestation("horst@example.com", &kp, fields);
let member = fresh_member(MemberCapabilities::All, None);
verify_attestation(&att, &pk, "alice@example.com", &member).unwrap();
}
#[test]
fn verify_fails_on_email_mismatch() {
let kp = make_kp();
let pk = kp.public_key();
let fields = signed_fields_for("alice@example.com", &MemberCapabilities::All, None);
let att = sign_attestation("horst@example.com", &kp, fields);
let member = fresh_member(MemberCapabilities::All, None);
let err = verify_attestation(&att, &pk, "bob@example.com", &member).unwrap_err();
assert!(matches!(err, JoyError::AuthFailed(msg) if msg.contains("email")));
}
#[test]
fn verify_fails_on_enrollment_verifier_mismatch_before_redemption() {
let kp = make_kp();
let pk = kp.public_key();
let fields = signed_fields_for("alice@example.com", &MemberCapabilities::All, Some("AAAA"));
let att = sign_attestation("horst@example.com", &kp, fields);
let member = fresh_member(MemberCapabilities::All, Some("BBBB".into()));
let err = verify_attestation(&att, &pk, "alice@example.com", &member).unwrap_err();
assert!(matches!(err, JoyError::AuthFailed(msg) if msg.contains("enrollment_verifier")));
}
}