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