1use std::collections::BTreeMap;
7use std::path::Path;
8
9use chrono::Utc;
10use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
11use serde::{Deserialize, Serialize};
12
13use crate::bundle::FindingBundle;
14use crate::project::Project;
15use crate::repo;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct SignedEnvelope {
20 pub finding_id: String,
21 pub signature: String,
23 pub public_key: String,
25 pub signed_at: String,
27 pub algorithm: String,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39pub struct ActorRecord {
40 pub id: String,
42 pub public_key: String,
44 #[serde(default = "default_algorithm")]
46 pub algorithm: String,
47 pub created_at: String,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub tier: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub orcid: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub access_clearance: Option<crate::access_tier::AccessTier>,
79}
80
81pub fn validate_orcid(s: &str) -> Result<String, String> {
88 let trimmed = s.trim();
89 let bare = trimmed
90 .strip_prefix("https://orcid.org/")
91 .or_else(|| trimmed.strip_prefix("http://orcid.org/"))
92 .or_else(|| trimmed.strip_prefix("orcid:"))
93 .unwrap_or(trimmed);
94 if bare.len() != 19 {
95 return Err(format!(
96 "ORCID must be 19 chars (0000-0000-0000-000X), got {}",
97 bare.len()
98 ));
99 }
100 let mut groups = bare.split('-');
101 for i in 0..4 {
102 let g = groups
103 .next()
104 .ok_or_else(|| format!("ORCID missing group {} of 4", i + 1))?;
105 if g.len() != 4 {
106 return Err(format!(
107 "ORCID group {} must be 4 chars, got {}",
108 i + 1,
109 g.len()
110 ));
111 }
112 for (j, c) in g.chars().enumerate() {
113 let allow_x = i == 3 && j == 3;
114 if !c.is_ascii_digit() && !(allow_x && c == 'X') {
115 return Err(format!(
116 "ORCID character '{c}' at group {} pos {} not a digit (or X check digit)",
117 i + 1,
118 j + 1
119 ));
120 }
121 }
122 }
123 if groups.next().is_some() {
124 return Err("ORCID has too many hyphenated groups".to_string());
125 }
126 Ok(bare.to_string())
127}
128
129fn default_algorithm() -> String {
130 "ed25519".to_string()
131}
132
133#[must_use]
146pub fn actor_can_auto_apply(actor: &ActorRecord, kind: &str) -> bool {
147 matches!(
148 (actor.tier.as_deref(), kind),
149 (Some("auto-notes"), "finding.note")
150 )
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct VerifyReport {
156 pub total_findings: usize,
157 pub signed: usize,
158 pub unsigned: usize,
159 pub valid: usize,
160 pub invalid: usize,
161 pub signers: Vec<String>,
162 #[serde(default)]
164 pub findings_with_threshold: usize,
165 #[serde(default)]
168 pub jointly_accepted: usize,
169}
170
171pub fn generate_keypair(output_dir: &Path) -> Result<String, String> {
176 use rand::rngs::OsRng;
177
178 std::fs::create_dir_all(output_dir)
179 .map_err(|e| format!("Failed to create output directory: {e}"))?;
180
181 let signing_key = SigningKey::generate(&mut OsRng);
182 let verifying_key = signing_key.verifying_key();
183
184 let private_hex = hex::encode(signing_key.to_bytes());
185 let public_hex = hex::encode(verifying_key.to_bytes());
186
187 let private_path = output_dir.join("private.key");
188 let public_path = output_dir.join("public.key");
189
190 std::fs::write(&private_path, &private_hex)
191 .map_err(|e| format!("Failed to write private key: {e}"))?;
192 std::fs::write(&public_path, &public_hex)
193 .map_err(|e| format!("Failed to write public key: {e}"))?;
194
195 Ok(public_hex)
196}
197
198pub fn canonical_json(finding: &FindingBundle) -> Result<String, String> {
213 let mut value =
214 serde_json::to_value(finding).map_err(|e| format!("Failed to serialize finding: {e}"))?;
215 if let Some(flags) = value.get_mut("flags").and_then(|v| v.as_object_mut()) {
216 flags.remove("jointly_accepted");
217 }
218 let sorted = sort_value(&value);
219 serde_json::to_string(&sorted).map_err(|e| format!("Failed to produce canonical JSON: {e}"))
220}
221
222fn sort_value(v: &serde_json::Value) -> serde_json::Value {
224 match v {
225 serde_json::Value::Object(map) => {
226 let sorted: BTreeMap<String, serde_json::Value> = map
227 .iter()
228 .map(|(k, v)| (k.clone(), sort_value(v)))
229 .collect();
230 serde_json::to_value(sorted).unwrap()
231 }
232 serde_json::Value::Array(arr) => {
233 serde_json::Value::Array(arr.iter().map(sort_value).collect())
234 }
235 other => other.clone(),
236 }
237}
238
239fn load_signing_key(path: &Path) -> Result<SigningKey, String> {
243 let hex_str =
244 std::fs::read_to_string(path).map_err(|e| format!("Failed to read private key: {e}"))?;
245 let bytes =
246 hex::decode(hex_str.trim()).map_err(|e| format!("Invalid hex in private key: {e}"))?;
247 let key_bytes: [u8; 32] = bytes
248 .try_into()
249 .map_err(|_| "Private key must be exactly 32 bytes".to_string())?;
250 Ok(SigningKey::from_bytes(&key_bytes))
251}
252
253pub fn load_signing_key_from_path(path: &Path) -> Result<SigningKey, String> {
262 load_signing_key(path)
263}
264
265pub fn sign_bytes(signing_key: &SigningKey, bytes: &[u8]) -> [u8; 64] {
268 signing_key.sign(bytes).to_bytes()
269}
270
271pub fn pubkey_hex(signing_key: &SigningKey) -> String {
273 hex::encode(signing_key.verifying_key().to_bytes())
274}
275
276fn load_verifying_key(path: &Path) -> Result<VerifyingKey, String> {
278 let hex_str =
279 std::fs::read_to_string(path).map_err(|e| format!("Failed to read public key: {e}"))?;
280 parse_verifying_key(hex_str.trim())
281}
282
283fn parse_verifying_key(hex_str: &str) -> Result<VerifyingKey, String> {
285 let bytes = hex::decode(hex_str).map_err(|e| format!("Invalid hex in public key: {e}"))?;
286 let key_bytes: [u8; 32] = bytes
287 .try_into()
288 .map_err(|_| "Public key must be exactly 32 bytes".to_string())?;
289 VerifyingKey::from_bytes(&key_bytes).map_err(|e| format!("Invalid public key: {e}"))
290}
291
292pub fn sign_finding(
294 finding: &FindingBundle,
295 signing_key: &SigningKey,
296) -> Result<SignedEnvelope, String> {
297 let canonical = canonical_json(finding)?;
298 let signature = signing_key.sign(canonical.as_bytes());
299 let public_key = signing_key.verifying_key();
300
301 Ok(SignedEnvelope {
302 finding_id: finding.id.clone(),
303 signature: hex::encode(signature.to_bytes()),
304 public_key: hex::encode(public_key.to_bytes()),
305 signed_at: Utc::now().to_rfc3339(),
306 algorithm: "ed25519".to_string(),
307 })
308}
309
310pub fn verify_finding(finding: &FindingBundle, envelope: &SignedEnvelope) -> Result<bool, String> {
312 if finding.id != envelope.finding_id {
313 return Ok(false);
314 }
315
316 let verifying_key = parse_verifying_key(&envelope.public_key)?;
317 let sig_bytes =
318 hex::decode(&envelope.signature).map_err(|e| format!("Invalid signature hex: {e}"))?;
319 let signature = ed25519_dalek::Signature::from_bytes(
320 &sig_bytes
321 .try_into()
322 .map_err(|_| "Signature must be 64 bytes")?,
323 );
324
325 let canonical = canonical_json(finding)?;
326 Ok(verifying_key
327 .verify(canonical.as_bytes(), &signature)
328 .is_ok())
329}
330
331#[allow(dead_code)]
333pub fn verify_finding_with_pubkey(
334 finding: &FindingBundle,
335 envelope: &SignedEnvelope,
336 expected_pubkey: &str,
337) -> Result<bool, String> {
338 if envelope.public_key != expected_pubkey {
339 return Ok(false);
340 }
341 verify_finding(finding, envelope)
342}
343
344pub fn event_signing_bytes(event: &crate::events::StateEvent) -> Result<Vec<u8>, String> {
353 use serde_json::json;
354 let preimage = json!({
355 "schema": event.schema,
356 "id": event.id,
357 "kind": event.kind,
358 "target": event.target,
359 "actor": event.actor,
360 "timestamp": event.timestamp,
361 "reason": event.reason,
362 "before_hash": event.before_hash,
363 "after_hash": event.after_hash,
364 "payload": event.payload,
365 "caveats": event.caveats,
366 });
367 crate::canonical::to_canonical_bytes(&preimage)
368}
369
370pub fn sign_event(
373 event: &crate::events::StateEvent,
374 signing_key: &SigningKey,
375) -> Result<String, String> {
376 let bytes = event_signing_bytes(event)?;
377 let signature = signing_key.sign(&bytes);
378 Ok(hex::encode(signature.to_bytes()))
379}
380
381pub fn verify_event_signature(
385 event: &crate::events::StateEvent,
386 expected_pubkey_hex: &str,
387) -> Result<bool, String> {
388 let signature_hex = event
389 .signature
390 .as_deref()
391 .ok_or_else(|| format!("event {} has no signature field", event.id))?;
392 let verifying_key = parse_verifying_key(expected_pubkey_hex)?;
393 let sig_bytes =
394 hex::decode(signature_hex).map_err(|e| format!("invalid signature hex: {e}"))?;
395 let signature = ed25519_dalek::Signature::from_bytes(
396 &sig_bytes
397 .try_into()
398 .map_err(|_| "Signature must be 64 bytes")?,
399 );
400 let bytes = event_signing_bytes(event)?;
401 Ok(verifying_key.verify(&bytes, &signature).is_ok())
402}
403
404pub fn proposal_signing_bytes(
413 proposal: &crate::proposals::StateProposal,
414) -> Result<Vec<u8>, String> {
415 use serde_json::json;
416 let preimage = json!({
417 "schema": proposal.schema,
418 "id": proposal.id,
419 "kind": proposal.kind,
420 "target": proposal.target,
421 "actor": proposal.actor,
422 "created_at": proposal.created_at,
423 "reason": proposal.reason,
424 "payload": proposal.payload,
425 "source_refs": proposal.source_refs,
426 "caveats": proposal.caveats,
427 });
428 crate::canonical::to_canonical_bytes(&preimage)
429}
430
431pub fn sign_proposal(
434 proposal: &crate::proposals::StateProposal,
435 signing_key: &SigningKey,
436) -> Result<String, String> {
437 let bytes = proposal_signing_bytes(proposal)?;
438 Ok(hex::encode(signing_key.sign(&bytes).to_bytes()))
439}
440
441pub fn verify_proposal_signature(
444 proposal: &crate::proposals::StateProposal,
445 signature_hex: &str,
446 expected_pubkey_hex: &str,
447) -> Result<bool, String> {
448 let verifying_key = parse_verifying_key(expected_pubkey_hex)?;
449 let sig_bytes =
450 hex::decode(signature_hex).map_err(|e| format!("invalid signature hex: {e}"))?;
451 let signature = ed25519_dalek::Signature::from_bytes(
452 &sig_bytes
453 .try_into()
454 .map_err(|_| "Signature must be 64 bytes")?,
455 );
456 let bytes = proposal_signing_bytes(proposal)?;
457 Ok(verifying_key.verify(&bytes, &signature).is_ok())
458}
459
460pub fn verify_action_signature(
466 signing_bytes: &[u8],
467 signature_hex: &str,
468 expected_pubkey_hex: &str,
469) -> Result<bool, String> {
470 let verifying_key = parse_verifying_key(expected_pubkey_hex)?;
471 let sig_bytes =
472 hex::decode(signature_hex).map_err(|e| format!("invalid signature hex: {e}"))?;
473 let signature = ed25519_dalek::Signature::from_bytes(
474 &sig_bytes
475 .try_into()
476 .map_err(|_| "Signature must be 64 bytes")?,
477 );
478 Ok(verifying_key.verify(signing_bytes, &signature).is_ok())
479}
480
481pub fn sign_frontier(frontier_path: &Path, private_key_path: &Path) -> Result<usize, String> {
493 let mut frontier: Project = repo::load_from_path(frontier_path)?;
494
495 let signing_key = load_signing_key(private_key_path)?;
496 let our_pubkey_hex = hex::encode(signing_key.verifying_key().to_bytes());
497
498 let mut signed_count = 0usize;
499
500 let finding_by_id = frontier
505 .findings
506 .iter()
507 .map(|finding| (finding.id.as_str(), finding))
508 .collect::<std::collections::HashMap<_, _>>();
509 let mut already_signed_by_us = std::collections::HashSet::new();
510 let mut stale_signed_by_us = std::collections::HashSet::new();
511 for envelope in &frontier.signatures {
512 if envelope.public_key != our_pubkey_hex {
513 continue;
514 }
515 let valid = finding_by_id
516 .get(envelope.finding_id.as_str())
517 .and_then(|finding| verify_finding(finding, envelope).ok())
518 .unwrap_or(false);
519 if valid {
520 already_signed_by_us.insert(envelope.finding_id.clone());
521 } else {
522 stale_signed_by_us.insert(envelope.finding_id.clone());
523 }
524 }
525 if !stale_signed_by_us.is_empty() {
526 frontier.signatures.retain(|envelope| {
527 envelope.public_key != our_pubkey_hex
528 || !stale_signed_by_us.contains(&envelope.finding_id)
529 });
530 already_signed_by_us.retain(|finding_id| !stale_signed_by_us.contains(finding_id));
531 }
532
533 for finding in &frontier.findings {
534 if already_signed_by_us.contains(&finding.id) {
535 continue;
536 }
537 let envelope = sign_finding(finding, &signing_key)?;
538 frontier.signatures.push(envelope);
539 signed_count += 1;
540 }
541
542 let actor_ids_for_key: std::collections::HashSet<String> = frontier
543 .actors
544 .iter()
545 .filter(|actor| actor.public_key == our_pubkey_hex)
546 .map(|actor| actor.id.clone())
547 .collect();
548 if !actor_ids_for_key.is_empty() {
549 for event in &mut frontier.events {
550 if event.signature.is_some()
551 || event.actor.r#type != "human"
552 || !actor_ids_for_key.contains(&event.actor.id)
553 {
554 continue;
555 }
556 event.signature = Some(sign_event(event, &signing_key)?);
557 signed_count += 1;
558 }
559 }
560
561 refresh_jointly_accepted(&mut frontier);
563
564 repo::save_to_path(frontier_path, &frontier)?;
565
566 Ok(signed_count)
567}
568
569#[must_use]
576pub fn signers_for(project: &Project, finding_id: &str) -> Vec<String> {
577 let Some(finding) = project.findings.iter().find(|f| f.id == finding_id) else {
578 return Vec::new();
579 };
580 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
581 for env in &project.signatures {
582 if env.finding_id != finding_id {
583 continue;
584 }
585 if seen.contains(&env.public_key) {
586 continue;
587 }
588 if let Ok(true) = verify_finding(finding, env) {
589 seen.insert(env.public_key.clone());
590 }
591 }
592 seen.into_iter().collect()
593}
594
595#[must_use]
597pub fn valid_signature_count(project: &Project, finding_id: &str) -> usize {
598 signers_for(project, finding_id).len()
599}
600
601#[must_use]
605pub fn threshold_met(project: &Project, finding_id: &str) -> bool {
606 let Some(finding) = project.findings.iter().find(|f| f.id == finding_id) else {
607 return false;
608 };
609 let Some(threshold) = finding.flags.signature_threshold else {
610 return false;
611 };
612 valid_signature_count(project, finding_id) >= threshold as usize
613}
614
615pub fn refresh_jointly_accepted(project: &mut Project) {
620 let truth: std::collections::HashMap<String, bool> = project
622 .findings
623 .iter()
624 .map(|f| (f.id.clone(), threshold_met(project, &f.id)))
625 .collect();
626 for f in &mut project.findings {
627 f.flags.jointly_accepted = truth.get(&f.id).copied().unwrap_or(false);
628 }
629}
630
631pub fn verify_frontier(
633 frontier_path: &Path,
634 pubkey_path: Option<&Path>,
635) -> Result<VerifyReport, String> {
636 let frontier: Project = repo::load_from_path(frontier_path)?;
637
638 verify_frontier_data(&frontier, pubkey_path)
639}
640
641pub fn verify_frontier_data(
643 frontier: &Project,
644 pubkey_path: Option<&Path>,
645) -> Result<VerifyReport, String> {
646 let expected_pubkey = match pubkey_path {
647 Some(path) => {
648 let key = load_verifying_key(path)?;
649 Some(hex::encode(key.to_bytes()))
650 }
651 None => None,
652 };
653
654 let finding_map: std::collections::HashMap<&str, &FindingBundle> = frontier
656 .findings
657 .iter()
658 .map(|f| (f.id.as_str(), f))
659 .collect();
660
661 let mut valid = 0usize;
668 let mut invalid = 0usize;
669 let mut signers: std::collections::HashSet<String> = std::collections::HashSet::new();
670 let mut findings_with_signature: std::collections::HashSet<&str> =
671 std::collections::HashSet::new();
672
673 for envelope in &frontier.signatures {
674 if let Some(ref expected) = expected_pubkey
675 && &envelope.public_key != expected
676 {
677 invalid += 1;
678 findings_with_signature.insert(envelope.finding_id.as_str());
679 continue;
680 }
681 let Some(finding) = finding_map.get(envelope.finding_id.as_str()) else {
682 invalid += 1;
683 continue;
684 };
685 findings_with_signature.insert(envelope.finding_id.as_str());
686 match verify_finding(finding, envelope) {
687 Ok(true) => {
688 valid += 1;
689 signers.insert(envelope.public_key.clone());
690 }
691 _ => {
692 invalid += 1;
693 }
694 }
695 }
696
697 let unsigned = frontier
698 .findings
699 .iter()
700 .filter(|f| !findings_with_signature.contains(f.id.as_str()))
701 .count();
702
703 let mut findings_with_threshold = 0usize;
705 let mut jointly_accepted = 0usize;
706 for f in &frontier.findings {
707 if f.flags.signature_threshold.is_some() {
708 findings_with_threshold += 1;
709 if threshold_met(frontier, &f.id) {
710 jointly_accepted += 1;
711 }
712 }
713 }
714
715 Ok(VerifyReport {
716 total_findings: frontier.findings.len(),
717 signed: valid + invalid,
718 unsigned,
719 valid,
720 invalid,
721 signers: signers.into_iter().collect(),
722 findings_with_threshold,
723 jointly_accepted,
724 })
725}
726
727#[cfg(test)]
730mod tests {
731 use super::*;
732 use crate::bundle::*;
733
734 fn sample_finding() -> FindingBundle {
735 FindingBundle::new(
736 Assertion {
737 text: "NLRP3 activates IL-1B".into(),
738 assertion_type: "mechanism".into(),
739 entities: vec![Entity {
740 name: "NLRP3".into(),
741 entity_type: "protein".into(),
742 identifiers: serde_json::Map::new(),
743 canonical_id: None,
744 candidates: vec![],
745 aliases: vec![],
746 resolution_provenance: None,
747 resolution_confidence: 1.0,
748 resolution_method: None,
749 species_context: None,
750 needs_review: false,
751 }],
752 relation: Some("activates".into()),
753 direction: Some("positive".into()),
754 causal_claim: None,
755 causal_evidence_grade: None,
756 },
757 Evidence {
758 evidence_type: "experimental".into(),
759 model_system: "mouse".into(),
760 species: Some("Mus musculus".into()),
761 method: "Western blot".into(),
762 sample_size: Some("n=30".into()),
763 effect_size: None,
764 p_value: Some("p<0.05".into()),
765 replicated: true,
766 replication_count: Some(3),
767 evidence_spans: vec![],
768 },
769 Conditions {
770 text: "In vitro, mouse microglia".into(),
771 species_verified: vec!["Mus musculus".into()],
772 species_unverified: vec![],
773 in_vitro: true,
774 in_vivo: false,
775 human_data: false,
776 clinical_trial: false,
777 concentration_range: None,
778 duration: None,
779 age_group: None,
780 cell_type: Some("microglia".into()),
781 },
782 Confidence::raw(0.85, "Experimental with replication", 0.9),
783 Provenance {
784 source_type: "published_paper".into(),
785 doi: Some("10.1234/test".into()),
786 pmid: None,
787 pmc: None,
788 openalex_id: None,
789 url: None,
790 title: "Test Paper".into(),
791 authors: vec![Author {
792 name: "Smith J".into(),
793 orcid: None,
794 }],
795 year: Some(2024),
796 journal: Some("Nature".into()),
797 license: None,
798 publisher: None,
799 funders: vec![],
800 extraction: Extraction::default(),
801 review: None,
802 citation_count: Some(100),
803 },
804 Flags {
805 gap: false,
806 negative_space: false,
807 contested: false,
808 retracted: false,
809 declining: false,
810 gravity_well: false,
811 review_state: None,
812 superseded: false,
813 signature_threshold: None,
814 jointly_accepted: false,
815 },
816 )
817 }
818
819 fn test_keypair() -> SigningKey {
820 use rand::rngs::OsRng;
821 SigningKey::generate(&mut OsRng)
822 }
823
824 #[test]
825 fn keygen_produces_valid_files() {
826 let dir = std::env::temp_dir().join("vela_test_keygen");
827 let _ = std::fs::remove_dir_all(&dir);
828
829 let pubkey = generate_keypair(&dir).unwrap();
830 assert_eq!(pubkey.len(), 64); let private_hex = std::fs::read_to_string(dir.join("private.key")).unwrap();
833 let public_hex = std::fs::read_to_string(dir.join("public.key")).unwrap();
834 assert_eq!(private_hex.len(), 64);
835 assert_eq!(public_hex, pubkey);
836
837 let _ = std::fs::remove_dir_all(&dir);
838 }
839
840 #[test]
841 fn sign_and_verify_roundtrip() {
842 let finding = sample_finding();
843 let key = test_keypair();
844
845 let envelope = sign_finding(&finding, &key).unwrap();
846 assert_eq!(envelope.finding_id, finding.id);
847 assert_eq!(envelope.algorithm, "ed25519");
848 assert_eq!(envelope.signature.len(), 128); let valid = verify_finding(&finding, &envelope).unwrap();
851 assert!(valid, "Signature should verify against original finding");
852 }
853
854 #[test]
855 fn tampered_finding_fails_verification() {
856 let finding = sample_finding();
857 let key = test_keypair();
858 let envelope = sign_finding(&finding, &key).unwrap();
859
860 let mut tampered = finding.clone();
862 tampered.assertion.text = "Tampered assertion text".into();
863
864 let valid = verify_finding(&tampered, &envelope).unwrap();
865 assert!(!valid, "Tampered finding should fail verification");
866 }
867
868 #[test]
869 fn sign_frontier_replaces_stale_same_key_signature() {
870 let dir = tempfile::tempdir().unwrap();
871 let frontier_path = dir.path().join("frontier.json");
872 let private_key_path = dir.path().join("private.key");
873 let key = test_keypair();
874 std::fs::write(&private_key_path, hex::encode(key.to_bytes())).unwrap();
875
876 let mut finding = sample_finding();
877 let stale_envelope = sign_finding(&finding, &key).unwrap();
878 finding.assertion.text = "NLRP3 activates IL-1B under revised scope".into();
879 let mut frontier = empty_project(vec![finding], vec![stale_envelope]);
880 crate::repo::save_to_path(&frontier_path, &frontier).unwrap();
881
882 let signed = sign_frontier(&frontier_path, &private_key_path).unwrap();
883 assert_eq!(signed, 1);
884
885 frontier = crate::repo::load_from_path(&frontier_path).unwrap();
886 let report = verify_frontier_data(&frontier, None).unwrap();
887 assert_eq!(report.valid, 1);
888 assert_eq!(report.invalid, 0);
889 assert_eq!(frontier.signatures.len(), 1);
890 }
891
892 #[test]
893 fn wrong_key_fails_verification() {
894 let finding = sample_finding();
895 let key1 = test_keypair();
896 let key2 = test_keypair();
897
898 let envelope = sign_finding(&finding, &key1).unwrap();
899 let pubkey2_hex = hex::encode(key2.verifying_key().to_bytes());
900
901 let valid = verify_finding_with_pubkey(&finding, &envelope, &pubkey2_hex).unwrap();
902 assert!(!valid, "Wrong public key should fail verification");
903 }
904
905 #[test]
906 fn canonical_json_is_deterministic() {
907 let finding = sample_finding();
908 let json1 = canonical_json(&finding).unwrap();
909 let json2 = canonical_json(&finding).unwrap();
910 assert_eq!(json1, json2, "Canonical JSON must be deterministic");
911 }
912
913 #[test]
914 fn registered_actor_signed_event_roundtrip() {
915 use crate::events::{
919 EVENT_SCHEMA, NULL_HASH, StateActor, StateEvent, StateTarget, compute_event_id,
920 };
921
922 let key = test_keypair();
923 let pubkey_hex = hex::encode(key.verifying_key().to_bytes());
924
925 let mut event = StateEvent {
926 schema: EVENT_SCHEMA.to_string(),
927 id: String::new(),
928 kind: "finding.reviewed".to_string(),
929 target: StateTarget {
930 r#type: "finding".to_string(),
931 id: "vf_test".to_string(),
932 },
933 actor: StateActor {
934 id: "reviewer:registered".to_string(),
935 r#type: "human".to_string(),
936 },
937 timestamp: "2026-04-25T00:00:00Z".to_string(),
938 reason: "phase-m round-trip test".to_string(),
939 before_hash: NULL_HASH.to_string(),
940 after_hash: "sha256:abc".to_string(),
941 payload: serde_json::json!({"status": "accepted", "proposal_id": "vpr_test"}),
942 caveats: vec![],
943 signature: None,
944 schema_artifact_id: None,
945 };
946 event.id = compute_event_id(&event);
947 event.signature = Some(sign_event(&event, &key).unwrap());
948
949 assert!(verify_event_signature(&event, &pubkey_hex).unwrap());
951
952 let mut tampered = event.clone();
954 tampered.reason = "different reason".to_string();
955 assert!(!verify_event_signature(&tampered, &pubkey_hex).unwrap());
956 }
957
958 #[test]
959 fn verify_frontier_data_reports_correctly() {
960 let f1 = sample_finding();
961 let mut f2 = sample_finding();
962 f2.id = "vf_other_id_12345".into();
963 f2.assertion.text = "Different finding".into();
964
965 let key = test_keypair();
966 let env1 = sign_finding(&f1, &key).unwrap();
967 let frontier = Project {
970 vela_version: "0.1.0".into(),
971 schema: "test".into(),
972 frontier_id: None,
973 project: crate::project::ProjectMeta {
974 name: "test".into(),
975 description: "test".into(),
976 compiled_at: "2024-01-01T00:00:00Z".into(),
977 compiler: "vela/0.2.0".into(),
978 papers_processed: 0,
979 errors: 0,
980 dependencies: Vec::new(),
981 },
982 stats: crate::project::ProjectStats {
983 findings: 2,
984 links: 0,
985 replicated: 0,
986 unreplicated: 2,
987 avg_confidence: 0.85,
988 gaps: 0,
989 negative_space: 0,
990 contested: 0,
991 categories: std::collections::HashMap::new(),
992 link_types: std::collections::HashMap::new(),
993 human_reviewed: 0,
994 review_event_count: 0,
995 confidence_update_count: 0,
996 event_count: 0,
997 source_count: 0,
998 evidence_atom_count: 0,
999 condition_record_count: 0,
1000 proposal_count: 0,
1001 confidence_distribution: crate::project::ConfidenceDistribution {
1002 high_gt_80: 2,
1003 medium_60_80: 0,
1004 low_lt_60: 0,
1005 },
1006 },
1007 findings: vec![f1, f2],
1008 sources: vec![],
1009 evidence_atoms: vec![],
1010 condition_records: vec![],
1011 review_events: vec![],
1012 confidence_updates: vec![],
1013 events: vec![],
1014 proposals: vec![],
1015 proof_state: Default::default(),
1016 signatures: vec![env1],
1017 actors: vec![],
1018 replications: vec![],
1019 datasets: vec![],
1020 code_artifacts: vec![],
1021 artifacts: vec![],
1022 predictions: vec![],
1023 resolutions: vec![],
1024 peers: vec![],
1025 negative_results: vec![],
1026 trajectories: vec![],
1027 };
1028
1029 let report = verify_frontier_data(&frontier, None).unwrap();
1030 assert_eq!(report.total_findings, 2);
1031 assert_eq!(report.signed, 1);
1032 assert_eq!(report.unsigned, 1);
1033 assert_eq!(report.valid, 1);
1034 assert_eq!(report.invalid, 0);
1035 assert_eq!(report.signers.len(), 1);
1036 }
1037
1038 fn empty_project(findings: Vec<FindingBundle>, signatures: Vec<SignedEnvelope>) -> Project {
1041 Project {
1042 vela_version: "0.37.0".into(),
1043 schema: "test".into(),
1044 frontier_id: None,
1045 project: crate::project::ProjectMeta {
1046 name: "test".into(),
1047 description: "test".into(),
1048 compiled_at: "2026-04-27T00:00:00Z".into(),
1049 compiler: "vela/0.37.0".into(),
1050 papers_processed: 0,
1051 errors: 0,
1052 dependencies: Vec::new(),
1053 },
1054 stats: crate::project::ProjectStats::default(),
1055 findings,
1056 sources: vec![],
1057 evidence_atoms: vec![],
1058 condition_records: vec![],
1059 review_events: vec![],
1060 confidence_updates: vec![],
1061 events: vec![],
1062 proposals: vec![],
1063 proof_state: Default::default(),
1064 signatures,
1065 actors: vec![],
1066 replications: vec![],
1067 datasets: vec![],
1068 code_artifacts: vec![],
1069 artifacts: vec![],
1070 predictions: vec![],
1071 resolutions: vec![],
1072 peers: vec![],
1073 negative_results: vec![],
1074 trajectories: vec![],
1075 }
1076 }
1077
1078 #[test]
1079 fn signers_for_dedupes_by_pubkey() {
1080 let mut f = sample_finding();
1081 f.flags.signature_threshold = Some(2);
1082 let key1 = test_keypair();
1083 let key2 = test_keypair();
1084 let env1 = sign_finding(&f, &key1).unwrap();
1085 let env1_dup = sign_finding(&f, &key1).unwrap();
1086 let env2 = sign_finding(&f, &key2).unwrap();
1087 let project = empty_project(vec![f.clone()], vec![env1, env1_dup, env2]);
1088 let signers = signers_for(&project, &f.id);
1089 assert_eq!(signers.len(), 2, "duplicate pubkey must be counted once");
1090 }
1091
1092 #[test]
1093 fn threshold_met_requires_k_unique_signers() {
1094 let mut f = sample_finding();
1095 f.flags.signature_threshold = Some(2);
1096 let key1 = test_keypair();
1097 let env1 = sign_finding(&f, &key1).unwrap();
1098 let project_one = empty_project(vec![f.clone()], vec![env1.clone()]);
1099 assert!(!threshold_met(&project_one, &f.id), "1 of 2 not met");
1100
1101 let key2 = test_keypair();
1102 let env2 = sign_finding(&f, &key2).unwrap();
1103 let project_two = empty_project(vec![f.clone()], vec![env1, env2]);
1104 assert!(threshold_met(&project_two, &f.id), "2 of 2 met");
1105 }
1106
1107 #[test]
1108 fn threshold_none_reports_not_met() {
1109 let f = sample_finding();
1110 let key = test_keypair();
1112 let env = sign_finding(&f, &key).unwrap();
1113 let project = empty_project(vec![f.clone()], vec![env]);
1114 assert!(
1115 !threshold_met(&project, &f.id),
1116 "no policy → never met (single-sig regime)"
1117 );
1118 }
1119
1120 #[test]
1121 fn refresh_jointly_accepted_sets_flag() {
1122 let mut f = sample_finding();
1123 f.flags.signature_threshold = Some(1);
1124 let key = test_keypair();
1125 let env = sign_finding(&f, &key).unwrap();
1126 let mut project = empty_project(vec![f.clone()], vec![env]);
1127 refresh_jointly_accepted(&mut project);
1128 assert!(project.findings[0].flags.jointly_accepted);
1129 }
1130
1131 #[test]
1132 fn invalid_signature_does_not_count_toward_threshold() {
1133 let mut f = sample_finding();
1134 f.flags.signature_threshold = Some(2);
1135 let key1 = test_keypair();
1136 let key2 = test_keypair();
1137 let env1 = sign_finding(&f, &key1).unwrap();
1138 let mut env2_tampered = sign_finding(&f, &key2).unwrap();
1139 env2_tampered.signature = "00".repeat(64);
1141 let project = empty_project(vec![f.clone()], vec![env1, env2_tampered]);
1142 assert_eq!(valid_signature_count(&project, &f.id), 1);
1143 assert!(!threshold_met(&project, &f.id));
1144 }
1145
1146 #[test]
1147 fn verify_report_surfaces_threshold_counts() {
1148 let mut f = sample_finding();
1149 f.flags.signature_threshold = Some(1);
1150 let key = test_keypair();
1151 let env = sign_finding(&f, &key).unwrap();
1152 let project = empty_project(vec![f.clone()], vec![env]);
1153 let report = verify_frontier_data(&project, None).unwrap();
1154 assert_eq!(report.findings_with_threshold, 1);
1155 assert_eq!(report.jointly_accepted, 1);
1156 }
1157
1158 #[test]
1161 fn validate_orcid_accepts_canonical_form() {
1162 assert_eq!(
1163 validate_orcid("0000-0001-2345-6789").unwrap(),
1164 "0000-0001-2345-6789"
1165 );
1166 }
1167
1168 #[test]
1169 fn validate_orcid_accepts_check_digit_x() {
1170 assert_eq!(
1171 validate_orcid("0000-0001-5109-393X").unwrap(),
1172 "0000-0001-5109-393X"
1173 );
1174 }
1175
1176 #[test]
1177 fn validate_orcid_strips_url_prefix() {
1178 assert_eq!(
1179 validate_orcid("https://orcid.org/0000-0001-2345-6789").unwrap(),
1180 "0000-0001-2345-6789"
1181 );
1182 }
1183
1184 #[test]
1185 fn validate_orcid_strips_orcid_prefix() {
1186 assert_eq!(
1187 validate_orcid("orcid:0000-0001-2345-6789").unwrap(),
1188 "0000-0001-2345-6789"
1189 );
1190 }
1191
1192 #[test]
1193 fn validate_orcid_rejects_short() {
1194 assert!(validate_orcid("0000-0001").is_err());
1195 }
1196
1197 #[test]
1198 fn validate_orcid_rejects_letters_in_non_check_position() {
1199 assert!(validate_orcid("0000-A001-2345-6789").is_err());
1200 }
1201
1202 #[test]
1203 fn validate_orcid_rejects_x_in_first_three_groups() {
1204 assert!(validate_orcid("000X-0001-2345-6789").is_err());
1205 }
1206
1207 #[test]
1208 fn validate_orcid_rejects_extra_groups() {
1209 assert!(validate_orcid("0000-0001-2345-6789-9999").is_err());
1210 }
1211}