use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PreRegistration {
pub title: String,
pub hypothesis: String,
pub methods: String,
pub analysis_plan: String,
pub notes: Option<String>,
}
impl PreRegistration {
pub fn new(
title: impl Into<String>,
hypothesis: impl Into<String>,
methods: impl Into<String>,
analysis_plan: impl Into<String>,
) -> Self {
Self {
title: title.into(),
hypothesis: hypothesis.into(),
methods: methods.into(),
analysis_plan: analysis_plan.into(),
notes: None,
}
}
pub fn with_notes(mut self, notes: impl Into<String>) -> Self {
self.notes = Some(notes.into());
self
}
pub fn commit(&self) -> PreRegistrationCommitment {
let serialized =
serde_json::to_string(self).expect("PreRegistration should always serialize");
let mut hasher = Sha256::new();
hasher.update(serialized.as_bytes());
let hash = hex::encode(hasher.finalize());
PreRegistrationCommitment { hash, created_at: chrono::Utc::now() }
}
pub fn verify_commitment(&self, commitment: &PreRegistrationCommitment) -> bool {
let current = self.commit();
current.hash == commitment.hash
}
pub fn reveal(
&self,
commitment: &PreRegistrationCommitment,
) -> Result<PreRegistrationReveal, PreRegistrationError> {
if !self.verify_commitment(commitment) {
return Err(PreRegistrationError::HashMismatch);
}
Ok(PreRegistrationReveal {
protocol: self.clone(),
commitment: commitment.clone(),
revealed_at: chrono::Utc::now(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PreRegistrationCommitment {
pub hash: String,
pub created_at: chrono::DateTime<chrono::Utc>,
}
impl PreRegistrationCommitment {
pub fn hash_bytes(&self) -> Vec<u8> {
hex::decode(&self.hash).unwrap_or_default()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreRegistrationReveal {
pub protocol: PreRegistration,
pub commitment: PreRegistrationCommitment,
pub revealed_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum TimestampProof {
GitCommit(String),
Rfc3161(Vec<u8>),
OpenTimestamps(Vec<u8>),
}
impl TimestampProof {
pub fn git(commit_hash: impl Into<String>) -> Self {
Self::GitCommit(commit_hash.into())
}
pub fn is_git(&self) -> bool {
matches!(self, Self::GitCommit(_))
}
pub fn git_commit(&self) -> Option<&str> {
match self {
Self::GitCommit(hash) => Some(hash),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedPreRegistration {
pub registration: PreRegistration,
pub commitment: PreRegistrationCommitment,
pub signature: String,
pub public_key: String,
pub timestamp_proof: Option<TimestampProof>,
}
impl SignedPreRegistration {
pub fn sign(registration: &PreRegistration, signing_key: &SigningKey) -> Self {
let commitment = registration.commit();
let signature = signing_key.sign(commitment.hash.as_bytes());
let public_key = signing_key.verifying_key();
Self {
registration: registration.clone(),
commitment,
signature: hex::encode(signature.to_bytes()),
public_key: hex::encode(public_key.to_bytes()),
timestamp_proof: None,
}
}
pub fn with_timestamp_proof(mut self, proof: TimestampProof) -> Self {
self.timestamp_proof = Some(proof);
self
}
pub fn verify(&self) -> Result<bool, PreRegistrationError> {
let pk_bytes =
hex::decode(&self.public_key).map_err(|_err| PreRegistrationError::InvalidPublicKey)?;
let pk_array: [u8; 32] =
pk_bytes.try_into().map_err(|_err| PreRegistrationError::InvalidPublicKey)?;
let public_key = VerifyingKey::from_bytes(&pk_array)
.map_err(|_err| PreRegistrationError::InvalidPublicKey)?;
let sig_bytes =
hex::decode(&self.signature).map_err(|_err| PreRegistrationError::InvalidSignature)?;
let sig_array: [u8; 64] =
sig_bytes.try_into().map_err(|_err| PreRegistrationError::InvalidSignature)?;
let signature = Signature::from_bytes(&sig_array);
Ok(public_key.verify(self.commitment.hash.as_bytes(), &signature).is_ok())
}
pub fn verify_full(&self) -> Result<bool, PreRegistrationError> {
if !self.verify()? {
return Ok(false);
}
Ok(self.registration.verify_commitment(&self.commitment))
}
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum PreRegistrationError {
#[error("Hash mismatch: pre-registration does not match commitment")]
HashMismatch,
#[error("Invalid public key")]
InvalidPublicKey,
#[error("Invalid signature")]
InvalidSignature,
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_registration() -> PreRegistration {
PreRegistration::new(
"Effect of Treatment A on Outcome B",
"Treatment A will improve Outcome B by at least 20%",
"Randomized controlled trial with 100 participants",
"Two-sample t-test with alpha=0.05",
)
}
#[test]
fn test_commit_creates_hash() {
let reg = create_test_registration();
let commitment = reg.commit();
assert!(!commitment.hash.is_empty());
assert_eq!(commitment.hash.len(), 64); }
#[test]
fn test_commit_is_deterministic() {
let reg = create_test_registration();
let commitment1 = reg.commit();
let commitment2 = reg.commit();
assert_eq!(commitment1.hash, commitment2.hash);
}
#[test]
fn test_reveal_verifies_hash() {
let reg = create_test_registration();
let commitment = reg.commit();
let reveal = reg.reveal(&commitment);
assert!(reveal.is_ok());
let reveal = reveal.expect("operation should succeed");
assert_eq!(reveal.protocol, reg);
}
#[test]
fn test_reveal_fails_on_tamper() {
let reg = create_test_registration();
let commitment = reg.commit();
let tampered = PreRegistration::new(
"Effect of Treatment A on Outcome B",
"Treatment A will improve Outcome B by at least 50%", "Randomized controlled trial with 100 participants",
"Two-sample t-test with alpha=0.05",
);
let result = tampered.reveal(&commitment);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), PreRegistrationError::HashMismatch));
}
#[test]
fn test_timestamp_proof_git() {
let proof = TimestampProof::git("abc123def456");
assert!(proof.is_git());
assert_eq!(proof.git_commit(), Some("abc123def456"));
}
#[test]
fn test_timestamp_proof_rfc3161() {
let proof = TimestampProof::Rfc3161(vec![1, 2, 3, 4]);
assert!(!proof.is_git());
assert_eq!(proof.git_commit(), None);
}
#[test]
fn test_ed25519_signing() {
let reg = create_test_registration();
let signing_key = SigningKey::from_bytes(&[1u8; 32]);
let signed = SignedPreRegistration::sign(®, &signing_key);
assert!(!signed.signature.is_empty());
assert!(!signed.public_key.is_empty());
assert_eq!(signed.signature.len(), 128); assert_eq!(signed.public_key.len(), 64); }
#[test]
fn test_ed25519_verification() {
let reg = create_test_registration();
let signing_key = SigningKey::from_bytes(&[42u8; 32]);
let signed = SignedPreRegistration::sign(®, &signing_key);
assert!(signed.verify().expect("operation should succeed"));
assert!(signed.verify_full().expect("operation should succeed"));
}
#[test]
fn test_ed25519_verification_fails_on_tamper() {
let reg = create_test_registration();
let signing_key = SigningKey::from_bytes(&[42u8; 32]);
let mut signed = SignedPreRegistration::sign(®, &signing_key);
signed.commitment.hash = "0".repeat(64);
assert!(!signed.verify().expect("operation should succeed"));
}
#[test]
fn test_signed_with_timestamp() {
let reg = create_test_registration();
let signing_key = SigningKey::from_bytes(&[1u8; 32]);
let signed = SignedPreRegistration::sign(®, &signing_key)
.with_timestamp_proof(TimestampProof::git("deadbeef"));
assert!(signed.timestamp_proof.is_some());
assert!(signed.timestamp_proof.as_ref().expect("operation should succeed").is_git());
}
#[test]
fn test_commitment_hash_bytes() {
let reg = create_test_registration();
let commitment = reg.commit();
let bytes = commitment.hash_bytes();
assert_eq!(bytes.len(), 32); }
#[test]
fn test_registration_with_notes() {
let reg = create_test_registration().with_notes("Additional protocol considerations");
assert_eq!(reg.notes, Some("Additional protocol considerations".to_string()));
let reg_without_notes = create_test_registration();
assert_ne!(reg.commit().hash, reg_without_notes.commit().hash);
}
#[test]
fn test_different_registrations_different_hashes() {
let reg1 = create_test_registration();
let reg2 = PreRegistration::new(
"Different Study",
"Different hypothesis",
"Different methods",
"Different analysis",
);
assert_ne!(reg1.commit().hash, reg2.commit().hash);
}
}