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> {
203 let value =
204 serde_json::to_value(finding).map_err(|e| format!("Failed to serialize finding: {e}"))?;
205 let sorted = sort_value(&value);
206 serde_json::to_string(&sorted).map_err(|e| format!("Failed to produce canonical JSON: {e}"))
207}
208
209fn sort_value(v: &serde_json::Value) -> serde_json::Value {
211 match v {
212 serde_json::Value::Object(map) => {
213 let sorted: BTreeMap<String, serde_json::Value> = map
214 .iter()
215 .map(|(k, v)| (k.clone(), sort_value(v)))
216 .collect();
217 serde_json::to_value(sorted).unwrap()
218 }
219 serde_json::Value::Array(arr) => {
220 serde_json::Value::Array(arr.iter().map(sort_value).collect())
221 }
222 other => other.clone(),
223 }
224}
225
226fn load_signing_key(path: &Path) -> Result<SigningKey, String> {
230 let hex_str =
231 std::fs::read_to_string(path).map_err(|e| format!("Failed to read private key: {e}"))?;
232 let bytes =
233 hex::decode(hex_str.trim()).map_err(|e| format!("Invalid hex in private key: {e}"))?;
234 let key_bytes: [u8; 32] = bytes
235 .try_into()
236 .map_err(|_| "Private key must be exactly 32 bytes".to_string())?;
237 Ok(SigningKey::from_bytes(&key_bytes))
238}
239
240pub fn load_signing_key_from_path(path: &Path) -> Result<SigningKey, String> {
249 load_signing_key(path)
250}
251
252pub fn sign_bytes(signing_key: &SigningKey, bytes: &[u8]) -> [u8; 64] {
255 signing_key.sign(bytes).to_bytes()
256}
257
258pub fn pubkey_hex(signing_key: &SigningKey) -> String {
260 hex::encode(signing_key.verifying_key().to_bytes())
261}
262
263fn load_verifying_key(path: &Path) -> Result<VerifyingKey, String> {
265 let hex_str =
266 std::fs::read_to_string(path).map_err(|e| format!("Failed to read public key: {e}"))?;
267 parse_verifying_key(hex_str.trim())
268}
269
270fn parse_verifying_key(hex_str: &str) -> Result<VerifyingKey, String> {
272 let bytes = hex::decode(hex_str).map_err(|e| format!("Invalid hex in public key: {e}"))?;
273 let key_bytes: [u8; 32] = bytes
274 .try_into()
275 .map_err(|_| "Public key must be exactly 32 bytes".to_string())?;
276 VerifyingKey::from_bytes(&key_bytes).map_err(|e| format!("Invalid public key: {e}"))
277}
278
279pub fn sign_finding(
281 finding: &FindingBundle,
282 signing_key: &SigningKey,
283) -> Result<SignedEnvelope, String> {
284 let canonical = canonical_json(finding)?;
285 let signature = signing_key.sign(canonical.as_bytes());
286 let public_key = signing_key.verifying_key();
287
288 Ok(SignedEnvelope {
289 finding_id: finding.id.clone(),
290 signature: hex::encode(signature.to_bytes()),
291 public_key: hex::encode(public_key.to_bytes()),
292 signed_at: Utc::now().to_rfc3339(),
293 algorithm: "ed25519".to_string(),
294 })
295}
296
297pub fn verify_finding(finding: &FindingBundle, envelope: &SignedEnvelope) -> Result<bool, String> {
299 if finding.id != envelope.finding_id {
300 return Ok(false);
301 }
302
303 let verifying_key = parse_verifying_key(&envelope.public_key)?;
304 let sig_bytes =
305 hex::decode(&envelope.signature).map_err(|e| format!("Invalid signature hex: {e}"))?;
306 let signature = ed25519_dalek::Signature::from_bytes(
307 &sig_bytes
308 .try_into()
309 .map_err(|_| "Signature must be 64 bytes")?,
310 );
311
312 let canonical = canonical_json(finding)?;
313 Ok(verifying_key
314 .verify(canonical.as_bytes(), &signature)
315 .is_ok())
316}
317
318#[allow(dead_code)]
320pub fn verify_finding_with_pubkey(
321 finding: &FindingBundle,
322 envelope: &SignedEnvelope,
323 expected_pubkey: &str,
324) -> Result<bool, String> {
325 if envelope.public_key != expected_pubkey {
326 return Ok(false);
327 }
328 verify_finding(finding, envelope)
329}
330
331pub fn event_signing_bytes(event: &crate::events::StateEvent) -> Result<Vec<u8>, String> {
340 use serde_json::json;
341 let preimage = json!({
342 "schema": event.schema,
343 "id": event.id,
344 "kind": event.kind,
345 "target": event.target,
346 "actor": event.actor,
347 "timestamp": event.timestamp,
348 "reason": event.reason,
349 "before_hash": event.before_hash,
350 "after_hash": event.after_hash,
351 "payload": event.payload,
352 "caveats": event.caveats,
353 });
354 crate::canonical::to_canonical_bytes(&preimage)
355}
356
357pub fn sign_event(
360 event: &crate::events::StateEvent,
361 signing_key: &SigningKey,
362) -> Result<String, String> {
363 let bytes = event_signing_bytes(event)?;
364 let signature = signing_key.sign(&bytes);
365 Ok(hex::encode(signature.to_bytes()))
366}
367
368pub fn verify_event_signature(
372 event: &crate::events::StateEvent,
373 expected_pubkey_hex: &str,
374) -> Result<bool, String> {
375 let signature_hex = event
376 .signature
377 .as_deref()
378 .ok_or_else(|| format!("event {} has no signature field", event.id))?;
379 let verifying_key = parse_verifying_key(expected_pubkey_hex)?;
380 let sig_bytes =
381 hex::decode(signature_hex).map_err(|e| format!("invalid signature hex: {e}"))?;
382 let signature = ed25519_dalek::Signature::from_bytes(
383 &sig_bytes
384 .try_into()
385 .map_err(|_| "Signature must be 64 bytes")?,
386 );
387 let bytes = event_signing_bytes(event)?;
388 Ok(verifying_key.verify(&bytes, &signature).is_ok())
389}
390
391pub fn proposal_signing_bytes(
400 proposal: &crate::proposals::StateProposal,
401) -> Result<Vec<u8>, String> {
402 use serde_json::json;
403 let preimage = json!({
404 "schema": proposal.schema,
405 "id": proposal.id,
406 "kind": proposal.kind,
407 "target": proposal.target,
408 "actor": proposal.actor,
409 "created_at": proposal.created_at,
410 "reason": proposal.reason,
411 "payload": proposal.payload,
412 "source_refs": proposal.source_refs,
413 "caveats": proposal.caveats,
414 });
415 crate::canonical::to_canonical_bytes(&preimage)
416}
417
418pub fn sign_proposal(
421 proposal: &crate::proposals::StateProposal,
422 signing_key: &SigningKey,
423) -> Result<String, String> {
424 let bytes = proposal_signing_bytes(proposal)?;
425 Ok(hex::encode(signing_key.sign(&bytes).to_bytes()))
426}
427
428pub fn verify_proposal_signature(
431 proposal: &crate::proposals::StateProposal,
432 signature_hex: &str,
433 expected_pubkey_hex: &str,
434) -> Result<bool, String> {
435 let verifying_key = parse_verifying_key(expected_pubkey_hex)?;
436 let sig_bytes =
437 hex::decode(signature_hex).map_err(|e| format!("invalid signature hex: {e}"))?;
438 let signature = ed25519_dalek::Signature::from_bytes(
439 &sig_bytes
440 .try_into()
441 .map_err(|_| "Signature must be 64 bytes")?,
442 );
443 let bytes = proposal_signing_bytes(proposal)?;
444 Ok(verifying_key.verify(&bytes, &signature).is_ok())
445}
446
447pub fn verify_action_signature(
453 signing_bytes: &[u8],
454 signature_hex: &str,
455 expected_pubkey_hex: &str,
456) -> Result<bool, String> {
457 let verifying_key = parse_verifying_key(expected_pubkey_hex)?;
458 let sig_bytes =
459 hex::decode(signature_hex).map_err(|e| format!("invalid signature hex: {e}"))?;
460 let signature = ed25519_dalek::Signature::from_bytes(
461 &sig_bytes
462 .try_into()
463 .map_err(|_| "Signature must be 64 bytes")?,
464 );
465 Ok(verifying_key.verify(signing_bytes, &signature).is_ok())
466}
467
468pub fn sign_frontier(frontier_path: &Path, private_key_path: &Path) -> Result<usize, String> {
480 let mut frontier: Project = repo::load_from_path(frontier_path)?;
481
482 let signing_key = load_signing_key(private_key_path)?;
483 let our_pubkey_hex = hex::encode(signing_key.verifying_key().to_bytes());
484
485 let mut signed_count = 0usize;
486
487 let finding_by_id = frontier
492 .findings
493 .iter()
494 .map(|finding| (finding.id.as_str(), finding))
495 .collect::<std::collections::HashMap<_, _>>();
496 let mut already_signed_by_us = std::collections::HashSet::new();
497 let mut stale_signed_by_us = std::collections::HashSet::new();
498 for envelope in &frontier.signatures {
499 if envelope.public_key != our_pubkey_hex {
500 continue;
501 }
502 let valid = finding_by_id
503 .get(envelope.finding_id.as_str())
504 .and_then(|finding| verify_finding(finding, envelope).ok())
505 .unwrap_or(false);
506 if valid {
507 already_signed_by_us.insert(envelope.finding_id.clone());
508 } else {
509 stale_signed_by_us.insert(envelope.finding_id.clone());
510 }
511 }
512 if !stale_signed_by_us.is_empty() {
513 frontier.signatures.retain(|envelope| {
514 envelope.public_key != our_pubkey_hex
515 || !stale_signed_by_us.contains(&envelope.finding_id)
516 });
517 already_signed_by_us.retain(|finding_id| !stale_signed_by_us.contains(finding_id));
518 }
519
520 for finding in &frontier.findings {
521 if already_signed_by_us.contains(&finding.id) {
522 continue;
523 }
524 let envelope = sign_finding(finding, &signing_key)?;
525 frontier.signatures.push(envelope);
526 signed_count += 1;
527 }
528
529 let actor_ids_for_key: std::collections::HashSet<String> = frontier
530 .actors
531 .iter()
532 .filter(|actor| actor.public_key == our_pubkey_hex)
533 .map(|actor| actor.id.clone())
534 .collect();
535 if !actor_ids_for_key.is_empty() {
536 for event in &mut frontier.events {
537 if event.signature.is_some()
538 || event.actor.r#type != "human"
539 || !actor_ids_for_key.contains(&event.actor.id)
540 {
541 continue;
542 }
543 event.signature = Some(sign_event(event, &signing_key)?);
544 signed_count += 1;
545 }
546 }
547
548 refresh_jointly_accepted(&mut frontier);
550
551 repo::save_to_path(frontier_path, &frontier)?;
552
553 Ok(signed_count)
554}
555
556#[must_use]
563pub fn signers_for(project: &Project, finding_id: &str) -> Vec<String> {
564 let Some(finding) = project.findings.iter().find(|f| f.id == finding_id) else {
565 return Vec::new();
566 };
567 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
568 for env in &project.signatures {
569 if env.finding_id != finding_id {
570 continue;
571 }
572 if seen.contains(&env.public_key) {
573 continue;
574 }
575 if let Ok(true) = verify_finding(finding, env) {
576 seen.insert(env.public_key.clone());
577 }
578 }
579 seen.into_iter().collect()
580}
581
582#[must_use]
584pub fn valid_signature_count(project: &Project, finding_id: &str) -> usize {
585 signers_for(project, finding_id).len()
586}
587
588#[must_use]
592pub fn threshold_met(project: &Project, finding_id: &str) -> bool {
593 let Some(finding) = project.findings.iter().find(|f| f.id == finding_id) else {
594 return false;
595 };
596 let Some(threshold) = finding.flags.signature_threshold else {
597 return false;
598 };
599 valid_signature_count(project, finding_id) >= threshold as usize
600}
601
602pub fn refresh_jointly_accepted(project: &mut Project) {
607 let truth: std::collections::HashMap<String, bool> = project
609 .findings
610 .iter()
611 .map(|f| (f.id.clone(), threshold_met(project, &f.id)))
612 .collect();
613 for f in &mut project.findings {
614 f.flags.jointly_accepted = truth.get(&f.id).copied().unwrap_or(false);
615 }
616}
617
618pub fn verify_frontier(
620 frontier_path: &Path,
621 pubkey_path: Option<&Path>,
622) -> Result<VerifyReport, String> {
623 let frontier: Project = repo::load_from_path(frontier_path)?;
624
625 verify_frontier_data(&frontier, pubkey_path)
626}
627
628pub fn verify_frontier_data(
630 frontier: &Project,
631 pubkey_path: Option<&Path>,
632) -> Result<VerifyReport, String> {
633 let expected_pubkey = match pubkey_path {
634 Some(path) => {
635 let key = load_verifying_key(path)?;
636 Some(hex::encode(key.to_bytes()))
637 }
638 None => None,
639 };
640
641 let finding_map: std::collections::HashMap<&str, &FindingBundle> = frontier
643 .findings
644 .iter()
645 .map(|f| (f.id.as_str(), f))
646 .collect();
647
648 let sig_map: std::collections::HashMap<&str, &SignedEnvelope> = frontier
650 .signatures
651 .iter()
652 .map(|s| (s.finding_id.as_str(), s))
653 .collect();
654
655 let mut valid = 0usize;
656 let mut invalid = 0usize;
657 let mut unsigned = 0usize;
658 let mut signers: std::collections::HashSet<String> = std::collections::HashSet::new();
659
660 for finding in &frontier.findings {
661 match sig_map.get(finding.id.as_str()) {
662 None => {
663 unsigned += 1;
664 }
665 Some(envelope) => {
666 if let Some(ref expected) = expected_pubkey
668 && &envelope.public_key != expected
669 {
670 invalid += 1;
671 continue;
672 }
673
674 match verify_finding(finding, envelope) {
675 Ok(true) => {
676 valid += 1;
677 signers.insert(envelope.public_key.clone());
678 }
679 _ => {
680 invalid += 1;
681 }
682 }
683 }
684 }
685 }
686
687 let _ = finding_map; let mut findings_with_threshold = 0usize;
691 let mut jointly_accepted = 0usize;
692 for f in &frontier.findings {
693 if f.flags.signature_threshold.is_some() {
694 findings_with_threshold += 1;
695 if threshold_met(frontier, &f.id) {
696 jointly_accepted += 1;
697 }
698 }
699 }
700
701 Ok(VerifyReport {
702 total_findings: frontier.findings.len(),
703 signed: valid + invalid,
704 unsigned,
705 valid,
706 invalid,
707 signers: signers.into_iter().collect(),
708 findings_with_threshold,
709 jointly_accepted,
710 })
711}
712
713#[cfg(test)]
716mod tests {
717 use super::*;
718 use crate::bundle::*;
719
720 fn sample_finding() -> FindingBundle {
721 FindingBundle::new(
722 Assertion {
723 text: "NLRP3 activates IL-1B".into(),
724 assertion_type: "mechanism".into(),
725 entities: vec![Entity {
726 name: "NLRP3".into(),
727 entity_type: "protein".into(),
728 identifiers: serde_json::Map::new(),
729 canonical_id: None,
730 candidates: vec![],
731 aliases: vec![],
732 resolution_provenance: None,
733 resolution_confidence: 1.0,
734 resolution_method: None,
735 species_context: None,
736 needs_review: false,
737 }],
738 relation: Some("activates".into()),
739 direction: Some("positive".into()),
740 causal_claim: None,
741 causal_evidence_grade: None,
742 },
743 Evidence {
744 evidence_type: "experimental".into(),
745 model_system: "mouse".into(),
746 species: Some("Mus musculus".into()),
747 method: "Western blot".into(),
748 sample_size: Some("n=30".into()),
749 effect_size: None,
750 p_value: Some("p<0.05".into()),
751 replicated: true,
752 replication_count: Some(3),
753 evidence_spans: vec![],
754 },
755 Conditions {
756 text: "In vitro, mouse microglia".into(),
757 species_verified: vec!["Mus musculus".into()],
758 species_unverified: vec![],
759 in_vitro: true,
760 in_vivo: false,
761 human_data: false,
762 clinical_trial: false,
763 concentration_range: None,
764 duration: None,
765 age_group: None,
766 cell_type: Some("microglia".into()),
767 },
768 Confidence::raw(0.85, "Experimental with replication", 0.9),
769 Provenance {
770 source_type: "published_paper".into(),
771 doi: Some("10.1234/test".into()),
772 pmid: None,
773 pmc: None,
774 openalex_id: None,
775 url: None,
776 title: "Test Paper".into(),
777 authors: vec![Author {
778 name: "Smith J".into(),
779 orcid: None,
780 }],
781 year: Some(2024),
782 journal: Some("Nature".into()),
783 license: None,
784 publisher: None,
785 funders: vec![],
786 extraction: Extraction::default(),
787 review: None,
788 citation_count: Some(100),
789 },
790 Flags {
791 gap: false,
792 negative_space: false,
793 contested: false,
794 retracted: false,
795 declining: false,
796 gravity_well: false,
797 review_state: None,
798 superseded: false,
799 signature_threshold: None,
800 jointly_accepted: false,
801 },
802 )
803 }
804
805 fn test_keypair() -> SigningKey {
806 use rand::rngs::OsRng;
807 SigningKey::generate(&mut OsRng)
808 }
809
810 #[test]
811 fn keygen_produces_valid_files() {
812 let dir = std::env::temp_dir().join("vela_test_keygen");
813 let _ = std::fs::remove_dir_all(&dir);
814
815 let pubkey = generate_keypair(&dir).unwrap();
816 assert_eq!(pubkey.len(), 64); let private_hex = std::fs::read_to_string(dir.join("private.key")).unwrap();
819 let public_hex = std::fs::read_to_string(dir.join("public.key")).unwrap();
820 assert_eq!(private_hex.len(), 64);
821 assert_eq!(public_hex, pubkey);
822
823 let _ = std::fs::remove_dir_all(&dir);
824 }
825
826 #[test]
827 fn sign_and_verify_roundtrip() {
828 let finding = sample_finding();
829 let key = test_keypair();
830
831 let envelope = sign_finding(&finding, &key).unwrap();
832 assert_eq!(envelope.finding_id, finding.id);
833 assert_eq!(envelope.algorithm, "ed25519");
834 assert_eq!(envelope.signature.len(), 128); let valid = verify_finding(&finding, &envelope).unwrap();
837 assert!(valid, "Signature should verify against original finding");
838 }
839
840 #[test]
841 fn tampered_finding_fails_verification() {
842 let finding = sample_finding();
843 let key = test_keypair();
844 let envelope = sign_finding(&finding, &key).unwrap();
845
846 let mut tampered = finding.clone();
848 tampered.assertion.text = "Tampered assertion text".into();
849
850 let valid = verify_finding(&tampered, &envelope).unwrap();
851 assert!(!valid, "Tampered finding should fail verification");
852 }
853
854 #[test]
855 fn sign_frontier_replaces_stale_same_key_signature() {
856 let dir = tempfile::tempdir().unwrap();
857 let frontier_path = dir.path().join("frontier.json");
858 let private_key_path = dir.path().join("private.key");
859 let key = test_keypair();
860 std::fs::write(&private_key_path, hex::encode(key.to_bytes())).unwrap();
861
862 let mut finding = sample_finding();
863 let stale_envelope = sign_finding(&finding, &key).unwrap();
864 finding.assertion.text = "NLRP3 activates IL-1B under revised scope".into();
865 let mut frontier = empty_project(vec![finding], vec![stale_envelope]);
866 crate::repo::save_to_path(&frontier_path, &frontier).unwrap();
867
868 let signed = sign_frontier(&frontier_path, &private_key_path).unwrap();
869 assert_eq!(signed, 1);
870
871 frontier = crate::repo::load_from_path(&frontier_path).unwrap();
872 let report = verify_frontier_data(&frontier, None).unwrap();
873 assert_eq!(report.valid, 1);
874 assert_eq!(report.invalid, 0);
875 assert_eq!(frontier.signatures.len(), 1);
876 }
877
878 #[test]
879 fn wrong_key_fails_verification() {
880 let finding = sample_finding();
881 let key1 = test_keypair();
882 let key2 = test_keypair();
883
884 let envelope = sign_finding(&finding, &key1).unwrap();
885 let pubkey2_hex = hex::encode(key2.verifying_key().to_bytes());
886
887 let valid = verify_finding_with_pubkey(&finding, &envelope, &pubkey2_hex).unwrap();
888 assert!(!valid, "Wrong public key should fail verification");
889 }
890
891 #[test]
892 fn canonical_json_is_deterministic() {
893 let finding = sample_finding();
894 let json1 = canonical_json(&finding).unwrap();
895 let json2 = canonical_json(&finding).unwrap();
896 assert_eq!(json1, json2, "Canonical JSON must be deterministic");
897 }
898
899 #[test]
900 fn registered_actor_signed_event_roundtrip() {
901 use crate::events::{
905 EVENT_SCHEMA, NULL_HASH, StateActor, StateEvent, StateTarget, compute_event_id,
906 };
907
908 let key = test_keypair();
909 let pubkey_hex = hex::encode(key.verifying_key().to_bytes());
910
911 let mut event = StateEvent {
912 schema: EVENT_SCHEMA.to_string(),
913 id: String::new(),
914 kind: "finding.reviewed".to_string(),
915 target: StateTarget {
916 r#type: "finding".to_string(),
917 id: "vf_test".to_string(),
918 },
919 actor: StateActor {
920 id: "reviewer:registered".to_string(),
921 r#type: "human".to_string(),
922 },
923 timestamp: "2026-04-25T00:00:00Z".to_string(),
924 reason: "phase-m round-trip test".to_string(),
925 before_hash: NULL_HASH.to_string(),
926 after_hash: "sha256:abc".to_string(),
927 payload: serde_json::json!({"status": "accepted", "proposal_id": "vpr_test"}),
928 caveats: vec![],
929 signature: None,
930 schema_artifact_id: None,
931 };
932 event.id = compute_event_id(&event);
933 event.signature = Some(sign_event(&event, &key).unwrap());
934
935 assert!(verify_event_signature(&event, &pubkey_hex).unwrap());
937
938 let mut tampered = event.clone();
940 tampered.reason = "different reason".to_string();
941 assert!(!verify_event_signature(&tampered, &pubkey_hex).unwrap());
942 }
943
944 #[test]
945 fn verify_frontier_data_reports_correctly() {
946 let f1 = sample_finding();
947 let mut f2 = sample_finding();
948 f2.id = "vf_other_id_12345".into();
949 f2.assertion.text = "Different finding".into();
950
951 let key = test_keypair();
952 let env1 = sign_finding(&f1, &key).unwrap();
953 let frontier = Project {
956 vela_version: "0.1.0".into(),
957 schema: "test".into(),
958 frontier_id: None,
959 project: crate::project::ProjectMeta {
960 name: "test".into(),
961 description: "test".into(),
962 compiled_at: "2024-01-01T00:00:00Z".into(),
963 compiler: "vela/0.2.0".into(),
964 papers_processed: 0,
965 errors: 0,
966 dependencies: Vec::new(),
967 },
968 stats: crate::project::ProjectStats {
969 findings: 2,
970 links: 0,
971 replicated: 0,
972 unreplicated: 2,
973 avg_confidence: 0.85,
974 gaps: 0,
975 negative_space: 0,
976 contested: 0,
977 categories: std::collections::HashMap::new(),
978 link_types: std::collections::HashMap::new(),
979 human_reviewed: 0,
980 review_event_count: 0,
981 confidence_update_count: 0,
982 event_count: 0,
983 source_count: 0,
984 evidence_atom_count: 0,
985 condition_record_count: 0,
986 proposal_count: 0,
987 confidence_distribution: crate::project::ConfidenceDistribution {
988 high_gt_80: 2,
989 medium_60_80: 0,
990 low_lt_60: 0,
991 },
992 },
993 findings: vec![f1, f2],
994 sources: vec![],
995 evidence_atoms: vec![],
996 condition_records: vec![],
997 review_events: vec![],
998 confidence_updates: vec![],
999 events: vec![],
1000 proposals: vec![],
1001 proof_state: Default::default(),
1002 signatures: vec![env1],
1003 actors: vec![],
1004 replications: vec![],
1005 datasets: vec![],
1006 code_artifacts: vec![],
1007 artifacts: vec![],
1008 predictions: vec![],
1009 resolutions: vec![],
1010 peers: vec![],
1011 negative_results: vec![],
1012 trajectories: vec![],
1013 };
1014
1015 let report = verify_frontier_data(&frontier, None).unwrap();
1016 assert_eq!(report.total_findings, 2);
1017 assert_eq!(report.signed, 1);
1018 assert_eq!(report.unsigned, 1);
1019 assert_eq!(report.valid, 1);
1020 assert_eq!(report.invalid, 0);
1021 assert_eq!(report.signers.len(), 1);
1022 }
1023
1024 fn empty_project(findings: Vec<FindingBundle>, signatures: Vec<SignedEnvelope>) -> Project {
1027 Project {
1028 vela_version: "0.37.0".into(),
1029 schema: "test".into(),
1030 frontier_id: None,
1031 project: crate::project::ProjectMeta {
1032 name: "test".into(),
1033 description: "test".into(),
1034 compiled_at: "2026-04-27T00:00:00Z".into(),
1035 compiler: "vela/0.37.0".into(),
1036 papers_processed: 0,
1037 errors: 0,
1038 dependencies: Vec::new(),
1039 },
1040 stats: crate::project::ProjectStats::default(),
1041 findings,
1042 sources: vec![],
1043 evidence_atoms: vec![],
1044 condition_records: vec![],
1045 review_events: vec![],
1046 confidence_updates: vec![],
1047 events: vec![],
1048 proposals: vec![],
1049 proof_state: Default::default(),
1050 signatures,
1051 actors: vec![],
1052 replications: vec![],
1053 datasets: vec![],
1054 code_artifacts: vec![],
1055 artifacts: vec![],
1056 predictions: vec![],
1057 resolutions: vec![],
1058 peers: vec![],
1059 negative_results: vec![],
1060 trajectories: vec![],
1061 }
1062 }
1063
1064 #[test]
1065 fn signers_for_dedupes_by_pubkey() {
1066 let mut f = sample_finding();
1067 f.flags.signature_threshold = Some(2);
1068 let key1 = test_keypair();
1069 let key2 = test_keypair();
1070 let env1 = sign_finding(&f, &key1).unwrap();
1071 let env1_dup = sign_finding(&f, &key1).unwrap();
1072 let env2 = sign_finding(&f, &key2).unwrap();
1073 let project = empty_project(vec![f.clone()], vec![env1, env1_dup, env2]);
1074 let signers = signers_for(&project, &f.id);
1075 assert_eq!(signers.len(), 2, "duplicate pubkey must be counted once");
1076 }
1077
1078 #[test]
1079 fn threshold_met_requires_k_unique_signers() {
1080 let mut f = sample_finding();
1081 f.flags.signature_threshold = Some(2);
1082 let key1 = test_keypair();
1083 let env1 = sign_finding(&f, &key1).unwrap();
1084 let project_one = empty_project(vec![f.clone()], vec![env1.clone()]);
1085 assert!(!threshold_met(&project_one, &f.id), "1 of 2 not met");
1086
1087 let key2 = test_keypair();
1088 let env2 = sign_finding(&f, &key2).unwrap();
1089 let project_two = empty_project(vec![f.clone()], vec![env1, env2]);
1090 assert!(threshold_met(&project_two, &f.id), "2 of 2 met");
1091 }
1092
1093 #[test]
1094 fn threshold_none_reports_not_met() {
1095 let f = sample_finding();
1096 let key = test_keypair();
1098 let env = sign_finding(&f, &key).unwrap();
1099 let project = empty_project(vec![f.clone()], vec![env]);
1100 assert!(
1101 !threshold_met(&project, &f.id),
1102 "no policy → never met (single-sig regime)"
1103 );
1104 }
1105
1106 #[test]
1107 fn refresh_jointly_accepted_sets_flag() {
1108 let mut f = sample_finding();
1109 f.flags.signature_threshold = Some(1);
1110 let key = test_keypair();
1111 let env = sign_finding(&f, &key).unwrap();
1112 let mut project = empty_project(vec![f.clone()], vec![env]);
1113 refresh_jointly_accepted(&mut project);
1114 assert!(project.findings[0].flags.jointly_accepted);
1115 }
1116
1117 #[test]
1118 fn invalid_signature_does_not_count_toward_threshold() {
1119 let mut f = sample_finding();
1120 f.flags.signature_threshold = Some(2);
1121 let key1 = test_keypair();
1122 let key2 = test_keypair();
1123 let env1 = sign_finding(&f, &key1).unwrap();
1124 let mut env2_tampered = sign_finding(&f, &key2).unwrap();
1125 env2_tampered.signature = "00".repeat(64);
1127 let project = empty_project(vec![f.clone()], vec![env1, env2_tampered]);
1128 assert_eq!(valid_signature_count(&project, &f.id), 1);
1129 assert!(!threshold_met(&project, &f.id));
1130 }
1131
1132 #[test]
1133 fn verify_report_surfaces_threshold_counts() {
1134 let mut f = sample_finding();
1135 f.flags.signature_threshold = Some(1);
1136 let key = test_keypair();
1137 let env = sign_finding(&f, &key).unwrap();
1138 let project = empty_project(vec![f.clone()], vec![env]);
1139 let report = verify_frontier_data(&project, None).unwrap();
1140 assert_eq!(report.findings_with_threshold, 1);
1141 assert_eq!(report.jointly_accepted, 1);
1142 }
1143
1144 #[test]
1147 fn validate_orcid_accepts_canonical_form() {
1148 assert_eq!(
1149 validate_orcid("0000-0001-2345-6789").unwrap(),
1150 "0000-0001-2345-6789"
1151 );
1152 }
1153
1154 #[test]
1155 fn validate_orcid_accepts_check_digit_x() {
1156 assert_eq!(
1157 validate_orcid("0000-0001-5109-393X").unwrap(),
1158 "0000-0001-5109-393X"
1159 );
1160 }
1161
1162 #[test]
1163 fn validate_orcid_strips_url_prefix() {
1164 assert_eq!(
1165 validate_orcid("https://orcid.org/0000-0001-2345-6789").unwrap(),
1166 "0000-0001-2345-6789"
1167 );
1168 }
1169
1170 #[test]
1171 fn validate_orcid_strips_orcid_prefix() {
1172 assert_eq!(
1173 validate_orcid("orcid:0000-0001-2345-6789").unwrap(),
1174 "0000-0001-2345-6789"
1175 );
1176 }
1177
1178 #[test]
1179 fn validate_orcid_rejects_short() {
1180 assert!(validate_orcid("0000-0001").is_err());
1181 }
1182
1183 #[test]
1184 fn validate_orcid_rejects_letters_in_non_check_position() {
1185 assert!(validate_orcid("0000-A001-2345-6789").is_err());
1186 }
1187
1188 #[test]
1189 fn validate_orcid_rejects_x_in_first_three_groups() {
1190 assert!(validate_orcid("000X-0001-2345-6789").is_err());
1191 }
1192
1193 #[test]
1194 fn validate_orcid_rejects_extra_groups() {
1195 assert!(validate_orcid("0000-0001-2345-6789-9999").is_err());
1196 }
1197}