Skip to main content

vela_protocol/
sign.rs

1//! Cryptographic signing for finding bundles — the trust infrastructure layer.
2//!
3//! Every finding event can be signed with Ed25519 and verified independently.
4//! Signatures cover the canonical JSON of the finding (deterministic, sorted keys).
5
6use 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/// A signed envelope wrapping a finding's cryptographic signature.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct SignedEnvelope {
20    pub finding_id: String,
21    /// Hex-encoded Ed25519 signature (128 hex chars = 64 bytes).
22    pub signature: String,
23    /// Hex-encoded public key of the signer (64 hex chars = 32 bytes).
24    pub public_key: String,
25    /// ISO 8601 timestamp of when the signature was produced.
26    pub signed_at: String,
27    /// Algorithm identifier (always "ed25519").
28    pub algorithm: String,
29}
30
31/// Phase M (v0.4): registered actor identity. Maps a stable `actor.id`
32/// to an Ed25519 public key, established at a specific timestamp.
33///
34/// Once an actor is registered in a frontier, any canonical event
35/// whose `actor.id` matches must carry a verifiable signature under
36/// `--strict`. Frontiers without registered actors retain the legacy
37/// "placeholder reviewer" rejection from v0.3 only.
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39pub struct ActorRecord {
40    /// Stable, namespaced identifier (e.g. "reviewer:will-blair").
41    pub id: String,
42    /// Hex-encoded Ed25519 public key (64 hex chars = 32 bytes).
43    pub public_key: String,
44    /// Algorithm identifier (always "ed25519").
45    #[serde(default = "default_algorithm")]
46    pub algorithm: String,
47    /// ISO 8601 timestamp of when the actor was registered.
48    pub created_at: String,
49    /// Phase α (v0.6): trust tier permitting one-call auto-apply for a
50    /// restricted set of low-risk proposal kinds. The only tier defined
51    /// in v0.6 is `"auto-notes"`, which permits `propose_and_apply_note`.
52    /// Tier is never honored for state-changing kinds (review, retract,
53    /// confidence_revise, caveated). Pre-v0.6 actors load with `None`
54    /// and behave exactly as before.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub tier: Option<String>,
57    /// v0.43: Optional ORCID identifier for cross-system identity.
58    /// Format: `0000-0000-0000-000X` (16 digits in 4 groups, final
59    /// character optionally `X` per ISO 7064). When set, the actor's
60    /// identity can be cross-referenced through the public ORCID
61    /// directory at `https://orcid.org/<orcid>`. The substrate stores
62    /// the pointer; it does not verify the ORCID exists online (that
63    /// is L4 work). Pre-v0.43 actors load with `None` and behave
64    /// exactly as before.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub orcid: Option<String>,
67    /// v0.51: Read-side access clearance. `None` (default) means
68    /// public-only access. `Some(Restricted)` permits reading
69    /// `Public` and `Restricted` tiered objects; `Some(Classified)`
70    /// permits all. Distinct from the v0.6 `tier` field above (which
71    /// gates write-side auto-apply). The two are intentionally
72    /// independent: an actor can have `tier: auto-notes` for fast
73    /// note application without any read clearance, or
74    /// `access_clearance: Classified` without any auto-apply
75    /// privilege. Pre-v0.51 actors load with `None` and behave
76    /// exactly as before — the field is purely additive.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub access_clearance: Option<crate::access_tier::AccessTier>,
79}
80
81/// v0.43: Validate an ORCID identifier's structural shape. ORCID IDs
82/// are 16 digits in 4 groups of 4 separated by hyphens, with the
83/// final character optionally being `X` (the ISO 7064 check digit).
84/// Accepts bare form `0000-0001-2345-6789`, the URL form
85/// `https://orcid.org/0000-...`, or the prefixed form `orcid:0000-...`
86/// and returns the bare form.
87pub 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/// Phase α (v0.6): authorization predicate for one-call auto-apply.
134///
135/// Returns `true` iff the actor's tier explicitly permits auto-applying
136/// the given event kind without prior human review. Doctrine: tier
137/// permits review-context kinds only (annotations); never state-changing
138/// kinds (review verdicts, retractions, confidence revisions). Adding
139/// state-changing auto-apply requires a broader tier model with
140/// explicit doctrine review.
141///
142/// Currently recognized:
143///   - `tier="auto-notes"` + `kind="finding.note"` → `true`
144///   - everything else → `false`
145#[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/// Result of verifying all signatures in a frontier.
154#[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    /// v0.37: number of findings carrying `flags.signature_threshold = Some(k)`.
163    #[serde(default)]
164    pub findings_with_threshold: usize,
165    /// v0.37: number of findings whose threshold is currently met (k
166    /// distinct unique-key valid signatures present).
167    #[serde(default)]
168    pub jointly_accepted: usize,
169}
170
171// ── Key generation ───────────────────────────────────────────────────
172
173/// Generate an Ed25519 keypair. Writes the private key to `output_dir/private.key`
174/// and the public key to `output_dir/public.key`. Both are hex-encoded.
175pub 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
198// ── Canonical JSON ───────────────────────────────────────────────────
199
200/// Produce deterministic canonical JSON for a finding bundle.
201/// Uses sorted keys (via serde_json::Value -> BTreeMap conversion) and compact format.
202pub 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
209/// Recursively sort all object keys in a JSON value.
210fn 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
226// ── Signing and verification ─────────────────────────────────────────
227
228/// Load a signing key from a hex-encoded file.
229fn 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
240/// v0.49.3: public key-loading and primitive-signing helpers so the
241/// hub (and any other downstream binary) can sign small JSON payloads
242/// — e.g., the `/.well-known/vela` manifest — without needing direct
243/// access to the ed25519_dalek dep or to the SigningKey type.
244
245/// Load a hex-encoded Ed25519 signing key from disk.
246///
247/// Same on-disk format `vela sign generate-keypair` writes.
248pub fn load_signing_key_from_path(path: &Path) -> Result<SigningKey, String> {
249    load_signing_key(path)
250}
251
252/// Sign arbitrary bytes with the given key. Returns the 64-byte
253/// signature.
254pub fn sign_bytes(signing_key: &SigningKey, bytes: &[u8]) -> [u8; 64] {
255    signing_key.sign(bytes).to_bytes()
256}
257
258/// Hex-encoded Ed25519 public key (64 chars) for the given signing key.
259pub fn pubkey_hex(signing_key: &SigningKey) -> String {
260    hex::encode(signing_key.verifying_key().to_bytes())
261}
262
263/// Load a verifying key from a hex-encoded file.
264fn 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
270/// Parse a verifying key from a hex string.
271fn 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
279/// Sign a single finding bundle, producing a SignedEnvelope.
280pub 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
297/// Verify a signed envelope against a finding bundle.
298pub 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/// Verify a finding against a specific public key (hex-encoded).
319#[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
331// ── Event signing (Phase M, v0.4) ────────────────────────────────────
332
333/// Compute the canonical signing bytes for a `StateEvent`. The `signature`
334/// field is excluded from the preimage (you can't sign over your own
335/// signature). The same canonical-JSON rule that derives `vev_…` is reused.
336///
337/// A second implementation must produce byte-identical signing bytes
338/// for the same event content; the verification rule depends on it.
339pub 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
357/// Sign a canonical event with an Ed25519 private key, returning a
358/// hex-encoded signature suitable for `event.signature`.
359pub 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
368/// Verify that `event.signature` is a valid Ed25519 signature over the
369/// canonical signing bytes of `event`, produced by the holder of the
370/// private key matching `expected_pubkey_hex`.
371pub 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
391// ── Proposal signing (Phase Q-w, v0.5) ───────────────────────────────
392
393/// Compute the canonical signing bytes for a `StateProposal`. The
394/// `signature` (held externally on the wire) is excluded from the
395/// preimage. Same canonical-JSON discipline as `event_signing_bytes`.
396///
397/// The proposal `id` is included, which deterministically pins the
398/// content (since `vpr_…` is content-addressed under Phase P).
399pub 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
418/// Sign a proposal with an Ed25519 private key, returning a hex-encoded
419/// signature suitable for transport on a write API.
420pub 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
428/// Verify a hex-encoded Ed25519 signature against the canonical signing
429/// bytes of `proposal`, using `expected_pubkey_hex` as the verifying key.
430pub 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
447/// Generic signature verifier for action-on-canonical-bytes: verify
448/// `signature_hex` is a valid Ed25519 signature over `signing_bytes`,
449/// produced by the holder of `expected_pubkey_hex`. Used by write
450/// actions that don't sign over a full proposal/event struct (e.g.,
451/// accept/reject decisions).
452pub 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
468// ── Project-level operations ────────────────────────────────────────
469
470/// Sign all findings in a frontier that are not yet signed BY THIS KEY.
471/// Returns the number of newly signed findings.
472///
473/// v0.37: dedupe is now `(finding_id, public_key)`, not `finding_id`
474/// alone. Prior versions stopped at the first signature on a finding;
475/// that prevented multi-actor co-signing. Different actors with
476/// different keys can now each contribute a `SignedEnvelope` to the
477/// same finding. Re-running `vela sign apply` with the same key is
478/// still idempotent.
479pub 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    // Already signed by THIS key and still valid for current bytes.
488    // If a finding changed after an earlier signature, drop the stale
489    // same-key envelope so this run can refresh it. Other actors'
490    // signatures stay.
491    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    // v0.37: refresh `jointly_accepted` flags after multi-sig writes.
549    refresh_jointly_accepted(&mut frontier);
550
551    repo::save_to_path(frontier_path, &frontier)?;
552
553    Ok(signed_count)
554}
555
556// ── Multi-sig helpers (v0.37) ────────────────────────────────────────
557
558/// Hex-encoded public keys of every actor whose `SignedEnvelope`
559/// targeting `finding_id` cryptographically verifies against the
560/// finding's canonical bytes. Duplicate signatures from the same key
561/// are counted once. Returns an empty Vec if the finding doesn't exist.
562#[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/// Number of unique valid signers on `finding_id`.
583#[must_use]
584pub fn valid_signature_count(project: &Project, finding_id: &str) -> usize {
585    signers_for(project, finding_id).len()
586}
587
588/// True iff `flags.signature_threshold` is `Some(k)` and `k` distinct
589/// valid signatures are present. `None` threshold means single-sig
590/// semantics — never reports threshold-met.
591#[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
602/// Walk every finding and (re)set `flags.jointly_accepted` to match
603/// the current state of `signature_threshold` and the multi-sig
604/// envelope set. Idempotent. Called from `sign_frontier` and the
605/// verify path so the flag never drifts from the underlying truth.
606pub fn refresh_jointly_accepted(project: &mut Project) {
607    // First snapshot the truth without holding a mutable borrow on findings.
608    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
618/// Verify all signatures in a frontier. Optionally filter by a specific public key.
619pub 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
628/// Verify all signatures in an in-memory frontier.
629pub 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    // Index findings by ID
642    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    // Index signatures by finding ID
649    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 filtering by pubkey, check it matches
667                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; // used for indexing above
688
689    // v0.37: count threshold flags + verified joint-accept state.
690    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// ── Tests ────────────────────────────────────────────────────────────
714
715#[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); // 32 bytes hex-encoded
817
818        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); // 64 bytes hex-encoded
835
836        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        // Tamper with the finding
847        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        // Phase M: a registered actor's event must sign-and-verify
902        // against its registered pubkey via `event_signing_bytes`. This
903        // is the load-bearing claim for the v0.4 strict-mode gate.
904        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        };
931        event.id = compute_event_id(&event);
932        event.signature = Some(sign_event(&event, &key).unwrap());
933
934        // Verifies against the registered pubkey.
935        assert!(verify_event_signature(&event, &pubkey_hex).unwrap());
936
937        // Tampering with the reason invalidates the signature.
938        let mut tampered = event.clone();
939        tampered.reason = "different reason".to_string();
940        assert!(!verify_event_signature(&tampered, &pubkey_hex).unwrap());
941    }
942
943    #[test]
944    fn verify_frontier_data_reports_correctly() {
945        let f1 = sample_finding();
946        let mut f2 = sample_finding();
947        f2.id = "vf_other_id_12345".into();
948        f2.assertion.text = "Different finding".into();
949
950        let key = test_keypair();
951        let env1 = sign_finding(&f1, &key).unwrap();
952        // Leave f2 unsigned
953
954        let frontier = Project {
955            vela_version: "0.1.0".into(),
956            schema: "test".into(),
957            frontier_id: None,
958            project: crate::project::ProjectMeta {
959                name: "test".into(),
960                description: "test".into(),
961                compiled_at: "2024-01-01T00:00:00Z".into(),
962                compiler: "vela/0.2.0".into(),
963                papers_processed: 0,
964                errors: 0,
965                dependencies: Vec::new(),
966            },
967            stats: crate::project::ProjectStats {
968                findings: 2,
969                links: 0,
970                replicated: 0,
971                unreplicated: 2,
972                avg_confidence: 0.85,
973                gaps: 0,
974                negative_space: 0,
975                contested: 0,
976                categories: std::collections::HashMap::new(),
977                link_types: std::collections::HashMap::new(),
978                human_reviewed: 0,
979                review_event_count: 0,
980                confidence_update_count: 0,
981                event_count: 0,
982                source_count: 0,
983                evidence_atom_count: 0,
984                condition_record_count: 0,
985                proposal_count: 0,
986                confidence_distribution: crate::project::ConfidenceDistribution {
987                    high_gt_80: 2,
988                    medium_60_80: 0,
989                    low_lt_60: 0,
990                },
991            },
992            findings: vec![f1, f2],
993            sources: vec![],
994            evidence_atoms: vec![],
995            condition_records: vec![],
996            review_events: vec![],
997            confidence_updates: vec![],
998            events: vec![],
999            proposals: vec![],
1000            proof_state: Default::default(),
1001            signatures: vec![env1],
1002            actors: vec![],
1003            replications: vec![],
1004            datasets: vec![],
1005            code_artifacts: vec![],
1006            artifacts: vec![],
1007            predictions: vec![],
1008            resolutions: vec![],
1009            peers: vec![],
1010            negative_results: vec![],
1011            trajectories: vec![],
1012        };
1013
1014        let report = verify_frontier_data(&frontier, None).unwrap();
1015        assert_eq!(report.total_findings, 2);
1016        assert_eq!(report.signed, 1);
1017        assert_eq!(report.unsigned, 1);
1018        assert_eq!(report.valid, 1);
1019        assert_eq!(report.invalid, 0);
1020        assert_eq!(report.signers.len(), 1);
1021    }
1022
1023    // ── v0.37 Multi-sig tests ────────────────────────────────────────
1024
1025    fn empty_project(findings: Vec<FindingBundle>, signatures: Vec<SignedEnvelope>) -> Project {
1026        Project {
1027            vela_version: "0.37.0".into(),
1028            schema: "test".into(),
1029            frontier_id: None,
1030            project: crate::project::ProjectMeta {
1031                name: "test".into(),
1032                description: "test".into(),
1033                compiled_at: "2026-04-27T00:00:00Z".into(),
1034                compiler: "vela/0.37.0".into(),
1035                papers_processed: 0,
1036                errors: 0,
1037                dependencies: Vec::new(),
1038            },
1039            stats: crate::project::ProjectStats::default(),
1040            findings,
1041            sources: vec![],
1042            evidence_atoms: vec![],
1043            condition_records: vec![],
1044            review_events: vec![],
1045            confidence_updates: vec![],
1046            events: vec![],
1047            proposals: vec![],
1048            proof_state: Default::default(),
1049            signatures,
1050            actors: vec![],
1051            replications: vec![],
1052            datasets: vec![],
1053            code_artifacts: vec![],
1054            artifacts: vec![],
1055            predictions: vec![],
1056            resolutions: vec![],
1057            peers: vec![],
1058            negative_results: vec![],
1059            trajectories: vec![],
1060        }
1061    }
1062
1063    #[test]
1064    fn signers_for_dedupes_by_pubkey() {
1065        let mut f = sample_finding();
1066        f.flags.signature_threshold = Some(2);
1067        let key1 = test_keypair();
1068        let key2 = test_keypair();
1069        let env1 = sign_finding(&f, &key1).unwrap();
1070        let env1_dup = sign_finding(&f, &key1).unwrap();
1071        let env2 = sign_finding(&f, &key2).unwrap();
1072        let project = empty_project(vec![f.clone()], vec![env1, env1_dup, env2]);
1073        let signers = signers_for(&project, &f.id);
1074        assert_eq!(signers.len(), 2, "duplicate pubkey must be counted once");
1075    }
1076
1077    #[test]
1078    fn threshold_met_requires_k_unique_signers() {
1079        let mut f = sample_finding();
1080        f.flags.signature_threshold = Some(2);
1081        let key1 = test_keypair();
1082        let env1 = sign_finding(&f, &key1).unwrap();
1083        let project_one = empty_project(vec![f.clone()], vec![env1.clone()]);
1084        assert!(!threshold_met(&project_one, &f.id), "1 of 2 not met");
1085
1086        let key2 = test_keypair();
1087        let env2 = sign_finding(&f, &key2).unwrap();
1088        let project_two = empty_project(vec![f.clone()], vec![env1, env2]);
1089        assert!(threshold_met(&project_two, &f.id), "2 of 2 met");
1090    }
1091
1092    #[test]
1093    fn threshold_none_reports_not_met() {
1094        let f = sample_finding();
1095        // signature_threshold defaults to None.
1096        let key = test_keypair();
1097        let env = sign_finding(&f, &key).unwrap();
1098        let project = empty_project(vec![f.clone()], vec![env]);
1099        assert!(
1100            !threshold_met(&project, &f.id),
1101            "no policy → never met (single-sig regime)"
1102        );
1103    }
1104
1105    #[test]
1106    fn refresh_jointly_accepted_sets_flag() {
1107        let mut f = sample_finding();
1108        f.flags.signature_threshold = Some(1);
1109        let key = test_keypair();
1110        let env = sign_finding(&f, &key).unwrap();
1111        let mut project = empty_project(vec![f.clone()], vec![env]);
1112        refresh_jointly_accepted(&mut project);
1113        assert!(project.findings[0].flags.jointly_accepted);
1114    }
1115
1116    #[test]
1117    fn invalid_signature_does_not_count_toward_threshold() {
1118        let mut f = sample_finding();
1119        f.flags.signature_threshold = Some(2);
1120        let key1 = test_keypair();
1121        let key2 = test_keypair();
1122        let env1 = sign_finding(&f, &key1).unwrap();
1123        let mut env2_tampered = sign_finding(&f, &key2).unwrap();
1124        // Replace signature bytes with garbage; key still claims to be key2.
1125        env2_tampered.signature = "00".repeat(64);
1126        let project = empty_project(vec![f.clone()], vec![env1, env2_tampered]);
1127        assert_eq!(valid_signature_count(&project, &f.id), 1);
1128        assert!(!threshold_met(&project, &f.id));
1129    }
1130
1131    #[test]
1132    fn verify_report_surfaces_threshold_counts() {
1133        let mut f = sample_finding();
1134        f.flags.signature_threshold = Some(1);
1135        let key = test_keypair();
1136        let env = sign_finding(&f, &key).unwrap();
1137        let project = empty_project(vec![f.clone()], vec![env]);
1138        let report = verify_frontier_data(&project, None).unwrap();
1139        assert_eq!(report.findings_with_threshold, 1);
1140        assert_eq!(report.jointly_accepted, 1);
1141    }
1142
1143    // ── v0.43 ORCID validation ───────────────────────────────────────
1144
1145    #[test]
1146    fn validate_orcid_accepts_canonical_form() {
1147        assert_eq!(
1148            validate_orcid("0000-0001-2345-6789").unwrap(),
1149            "0000-0001-2345-6789"
1150        );
1151    }
1152
1153    #[test]
1154    fn validate_orcid_accepts_check_digit_x() {
1155        assert_eq!(
1156            validate_orcid("0000-0001-5109-393X").unwrap(),
1157            "0000-0001-5109-393X"
1158        );
1159    }
1160
1161    #[test]
1162    fn validate_orcid_strips_url_prefix() {
1163        assert_eq!(
1164            validate_orcid("https://orcid.org/0000-0001-2345-6789").unwrap(),
1165            "0000-0001-2345-6789"
1166        );
1167    }
1168
1169    #[test]
1170    fn validate_orcid_strips_orcid_prefix() {
1171        assert_eq!(
1172            validate_orcid("orcid:0000-0001-2345-6789").unwrap(),
1173            "0000-0001-2345-6789"
1174        );
1175    }
1176
1177    #[test]
1178    fn validate_orcid_rejects_short() {
1179        assert!(validate_orcid("0000-0001").is_err());
1180    }
1181
1182    #[test]
1183    fn validate_orcid_rejects_letters_in_non_check_position() {
1184        assert!(validate_orcid("0000-A001-2345-6789").is_err());
1185    }
1186
1187    #[test]
1188    fn validate_orcid_rejects_x_in_first_three_groups() {
1189        assert!(validate_orcid("000X-0001-2345-6789").is_err());
1190    }
1191
1192    #[test]
1193    fn validate_orcid_rejects_extra_groups() {
1194        assert!(validate_orcid("0000-0001-2345-6789-9999").is_err());
1195    }
1196}