Skip to main content

entrenar/research/
preregistration.rs

1//! Pre-Registration with hash commitment (ENT-022)
2//!
3//! Provides cryptographic pre-registration for research protocols
4//! with hash commitments and Ed25519 signing.
5
6use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10/// Pre-registration protocol for research studies
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct PreRegistration {
13    /// Protocol title
14    pub title: String,
15    /// Research hypothesis
16    pub hypothesis: String,
17    /// Methodology description
18    pub methods: String,
19    /// Statistical analysis plan
20    pub analysis_plan: String,
21    /// Additional notes
22    pub notes: Option<String>,
23}
24
25impl PreRegistration {
26    /// Create a new pre-registration
27    pub fn new(
28        title: impl Into<String>,
29        hypothesis: impl Into<String>,
30        methods: impl Into<String>,
31        analysis_plan: impl Into<String>,
32    ) -> Self {
33        Self {
34            title: title.into(),
35            hypothesis: hypothesis.into(),
36            methods: methods.into(),
37            analysis_plan: analysis_plan.into(),
38            notes: None,
39        }
40    }
41
42    /// Add notes
43    pub fn with_notes(mut self, notes: impl Into<String>) -> Self {
44        self.notes = Some(notes.into());
45        self
46    }
47
48    /// Create a cryptographic commitment (SHA-256 hash)
49    pub fn commit(&self) -> PreRegistrationCommitment {
50        let serialized =
51            serde_json::to_string(self).expect("PreRegistration should always serialize");
52        let mut hasher = Sha256::new();
53        hasher.update(serialized.as_bytes());
54        let hash = hex::encode(hasher.finalize());
55
56        PreRegistrationCommitment { hash, created_at: chrono::Utc::now() }
57    }
58
59    /// Verify a commitment matches this pre-registration
60    pub fn verify_commitment(&self, commitment: &PreRegistrationCommitment) -> bool {
61        let current = self.commit();
62        current.hash == commitment.hash
63    }
64
65    /// Reveal and verify against a commitment
66    pub fn reveal(
67        &self,
68        commitment: &PreRegistrationCommitment,
69    ) -> Result<PreRegistrationReveal, PreRegistrationError> {
70        if !self.verify_commitment(commitment) {
71            return Err(PreRegistrationError::HashMismatch);
72        }
73
74        Ok(PreRegistrationReveal {
75            protocol: self.clone(),
76            commitment: commitment.clone(),
77            revealed_at: chrono::Utc::now(),
78        })
79    }
80}
81
82/// Cryptographic commitment to a pre-registration
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct PreRegistrationCommitment {
85    /// SHA-256 hash of the serialized pre-registration
86    pub hash: String,
87    /// Timestamp when commitment was created
88    pub created_at: chrono::DateTime<chrono::Utc>,
89}
90
91impl PreRegistrationCommitment {
92    /// Get the hash as bytes
93    pub fn hash_bytes(&self) -> Vec<u8> {
94        hex::decode(&self.hash).unwrap_or_default()
95    }
96}
97
98/// Revealed pre-registration with verification
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct PreRegistrationReveal {
101    /// The original protocol
102    pub protocol: PreRegistration,
103    /// The commitment that was verified
104    pub commitment: PreRegistrationCommitment,
105    /// Timestamp when revealed
106    pub revealed_at: chrono::DateTime<chrono::Utc>,
107}
108
109/// Timestamp proof types
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub enum TimestampProof {
112    /// Git commit hash
113    GitCommit(String),
114    /// RFC 3161 timestamp token
115    Rfc3161(Vec<u8>),
116    /// OpenTimestamps proof
117    OpenTimestamps(Vec<u8>),
118}
119
120impl TimestampProof {
121    /// Create a git commit proof
122    pub fn git(commit_hash: impl Into<String>) -> Self {
123        Self::GitCommit(commit_hash.into())
124    }
125
126    /// Check if this is a git commit proof
127    pub fn is_git(&self) -> bool {
128        matches!(self, Self::GitCommit(_))
129    }
130
131    /// Get git commit hash if this is a git proof
132    pub fn git_commit(&self) -> Option<&str> {
133        match self {
134            Self::GitCommit(hash) => Some(hash),
135            _ => None,
136        }
137    }
138}
139
140/// Pre-registered protocol with Ed25519 signature
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct SignedPreRegistration {
143    /// The pre-registration
144    pub registration: PreRegistration,
145    /// The commitment hash
146    pub commitment: PreRegistrationCommitment,
147    /// Ed25519 signature (hex-encoded)
148    pub signature: String,
149    /// Public key (hex-encoded)
150    pub public_key: String,
151    /// Optional timestamp proof
152    pub timestamp_proof: Option<TimestampProof>,
153}
154
155impl SignedPreRegistration {
156    /// Sign a pre-registration with an Ed25519 key
157    pub fn sign(registration: &PreRegistration, signing_key: &SigningKey) -> Self {
158        let commitment = registration.commit();
159        let signature = signing_key.sign(commitment.hash.as_bytes());
160        let public_key = signing_key.verifying_key();
161
162        Self {
163            registration: registration.clone(),
164            commitment,
165            signature: hex::encode(signature.to_bytes()),
166            public_key: hex::encode(public_key.to_bytes()),
167            timestamp_proof: None,
168        }
169    }
170
171    /// Add a timestamp proof
172    pub fn with_timestamp_proof(mut self, proof: TimestampProof) -> Self {
173        self.timestamp_proof = Some(proof);
174        self
175    }
176
177    /// Verify the signature
178    pub fn verify(&self) -> Result<bool, PreRegistrationError> {
179        // Decode public key
180        let pk_bytes =
181            hex::decode(&self.public_key).map_err(|_err| PreRegistrationError::InvalidPublicKey)?;
182        let pk_array: [u8; 32] =
183            pk_bytes.try_into().map_err(|_err| PreRegistrationError::InvalidPublicKey)?;
184        let public_key = VerifyingKey::from_bytes(&pk_array)
185            .map_err(|_err| PreRegistrationError::InvalidPublicKey)?;
186
187        // Decode signature
188        let sig_bytes =
189            hex::decode(&self.signature).map_err(|_err| PreRegistrationError::InvalidSignature)?;
190        let sig_array: [u8; 64] =
191            sig_bytes.try_into().map_err(|_err| PreRegistrationError::InvalidSignature)?;
192        let signature = Signature::from_bytes(&sig_array);
193
194        // Verify signature
195        Ok(public_key.verify(self.commitment.hash.as_bytes(), &signature).is_ok())
196    }
197
198    /// Verify both signature and that registration matches commitment
199    pub fn verify_full(&self) -> Result<bool, PreRegistrationError> {
200        // First verify the signature
201        if !self.verify()? {
202            return Ok(false);
203        }
204
205        // Then verify the registration matches the commitment
206        Ok(self.registration.verify_commitment(&self.commitment))
207    }
208}
209
210/// Pre-registration errors
211#[derive(Debug, Clone, thiserror::Error)]
212pub enum PreRegistrationError {
213    #[error("Hash mismatch: pre-registration does not match commitment")]
214    HashMismatch,
215    #[error("Invalid public key")]
216    InvalidPublicKey,
217    #[error("Invalid signature")]
218    InvalidSignature,
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    fn create_test_registration() -> PreRegistration {
226        PreRegistration::new(
227            "Effect of Treatment A on Outcome B",
228            "Treatment A will improve Outcome B by at least 20%",
229            "Randomized controlled trial with 100 participants",
230            "Two-sample t-test with alpha=0.05",
231        )
232    }
233
234    #[test]
235    fn test_commit_creates_hash() {
236        let reg = create_test_registration();
237        let commitment = reg.commit();
238
239        assert!(!commitment.hash.is_empty());
240        assert_eq!(commitment.hash.len(), 64); // SHA-256 = 32 bytes = 64 hex chars
241    }
242
243    #[test]
244    fn test_commit_is_deterministic() {
245        let reg = create_test_registration();
246        let commitment1 = reg.commit();
247        let commitment2 = reg.commit();
248
249        assert_eq!(commitment1.hash, commitment2.hash);
250    }
251
252    #[test]
253    fn test_reveal_verifies_hash() {
254        let reg = create_test_registration();
255        let commitment = reg.commit();
256        let reveal = reg.reveal(&commitment);
257
258        assert!(reveal.is_ok());
259        let reveal = reveal.expect("operation should succeed");
260        assert_eq!(reveal.protocol, reg);
261    }
262
263    #[test]
264    fn test_reveal_fails_on_tamper() {
265        let reg = create_test_registration();
266        let commitment = reg.commit();
267
268        // Tamper with the registration
269        let tampered = PreRegistration::new(
270            "Effect of Treatment A on Outcome B",
271            "Treatment A will improve Outcome B by at least 50%", // Changed!
272            "Randomized controlled trial with 100 participants",
273            "Two-sample t-test with alpha=0.05",
274        );
275
276        let result = tampered.reveal(&commitment);
277        assert!(result.is_err());
278        assert!(matches!(result.unwrap_err(), PreRegistrationError::HashMismatch));
279    }
280
281    #[test]
282    fn test_timestamp_proof_git() {
283        let proof = TimestampProof::git("abc123def456");
284
285        assert!(proof.is_git());
286        assert_eq!(proof.git_commit(), Some("abc123def456"));
287    }
288
289    #[test]
290    fn test_timestamp_proof_rfc3161() {
291        let proof = TimestampProof::Rfc3161(vec![1, 2, 3, 4]);
292
293        assert!(!proof.is_git());
294        assert_eq!(proof.git_commit(), None);
295    }
296
297    #[test]
298    fn test_ed25519_signing() {
299        let reg = create_test_registration();
300
301        // Generate a signing key
302        let signing_key = SigningKey::from_bytes(&[1u8; 32]);
303
304        // Sign the pre-registration
305        let signed = SignedPreRegistration::sign(&reg, &signing_key);
306
307        assert!(!signed.signature.is_empty());
308        assert!(!signed.public_key.is_empty());
309        assert_eq!(signed.signature.len(), 128); // Ed25519 sig = 64 bytes = 128 hex
310        assert_eq!(signed.public_key.len(), 64); // Ed25519 pk = 32 bytes = 64 hex
311    }
312
313    #[test]
314    fn test_ed25519_verification() {
315        let reg = create_test_registration();
316        let signing_key = SigningKey::from_bytes(&[42u8; 32]);
317        let signed = SignedPreRegistration::sign(&reg, &signing_key);
318
319        assert!(signed.verify().expect("operation should succeed"));
320        assert!(signed.verify_full().expect("operation should succeed"));
321    }
322
323    #[test]
324    fn test_ed25519_verification_fails_on_tamper() {
325        let reg = create_test_registration();
326        let signing_key = SigningKey::from_bytes(&[42u8; 32]);
327        let mut signed = SignedPreRegistration::sign(&reg, &signing_key);
328
329        // Tamper with the commitment hash
330        signed.commitment.hash = "0".repeat(64);
331
332        // Signature verification should fail
333        assert!(!signed.verify().expect("operation should succeed"));
334    }
335
336    #[test]
337    fn test_signed_with_timestamp() {
338        let reg = create_test_registration();
339        let signing_key = SigningKey::from_bytes(&[1u8; 32]);
340
341        let signed = SignedPreRegistration::sign(&reg, &signing_key)
342            .with_timestamp_proof(TimestampProof::git("deadbeef"));
343
344        assert!(signed.timestamp_proof.is_some());
345        assert!(signed.timestamp_proof.as_ref().expect("operation should succeed").is_git());
346    }
347
348    #[test]
349    fn test_commitment_hash_bytes() {
350        let reg = create_test_registration();
351        let commitment = reg.commit();
352
353        let bytes = commitment.hash_bytes();
354        assert_eq!(bytes.len(), 32); // SHA-256 = 32 bytes
355    }
356
357    #[test]
358    fn test_registration_with_notes() {
359        let reg = create_test_registration().with_notes("Additional protocol considerations");
360
361        assert_eq!(reg.notes, Some("Additional protocol considerations".to_string()));
362
363        // Notes should affect the hash
364        let reg_without_notes = create_test_registration();
365        assert_ne!(reg.commit().hash, reg_without_notes.commit().hash);
366    }
367
368    #[test]
369    fn test_different_registrations_different_hashes() {
370        let reg1 = create_test_registration();
371        let reg2 = PreRegistration::new(
372            "Different Study",
373            "Different hypothesis",
374            "Different methods",
375            "Different analysis",
376        );
377
378        assert_ne!(reg1.commit().hash, reg2.commit().hash);
379    }
380}