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.
202///
203/// `flags.jointly_accepted` is excluded from the signing preimage. The flag
204/// is a derived cache that the substrate flips when the v0.37 multi-sig
205/// threshold is met; including it in the canonical bytes meant every
206/// signature that *triggered* the flip became invalid the moment the
207/// flip mutated the bytes. Stripping the field here keeps signatures
208/// stable across flag changes while leaving the on-disk projection of
209/// `jointly_accepted` intact for tooling. `signature_threshold` stays
210/// in the preimage so an attacker cannot lower the threshold without
211/// invalidating signatures.
212pub fn canonical_json(finding: &FindingBundle) -> Result<String, String> {
213    let mut value =
214        serde_json::to_value(finding).map_err(|e| format!("Failed to serialize finding: {e}"))?;
215    if let Some(flags) = value.get_mut("flags").and_then(|v| v.as_object_mut()) {
216        flags.remove("jointly_accepted");
217    }
218    let sorted = sort_value(&value);
219    serde_json::to_string(&sorted).map_err(|e| format!("Failed to produce canonical JSON: {e}"))
220}
221
222/// Recursively sort all object keys in a JSON value.
223fn sort_value(v: &serde_json::Value) -> serde_json::Value {
224    match v {
225        serde_json::Value::Object(map) => {
226            let sorted: BTreeMap<String, serde_json::Value> = map
227                .iter()
228                .map(|(k, v)| (k.clone(), sort_value(v)))
229                .collect();
230            serde_json::to_value(sorted).unwrap()
231        }
232        serde_json::Value::Array(arr) => {
233            serde_json::Value::Array(arr.iter().map(sort_value).collect())
234        }
235        other => other.clone(),
236    }
237}
238
239// ── Signing and verification ─────────────────────────────────────────
240
241/// Load a signing key from a hex-encoded file.
242fn load_signing_key(path: &Path) -> Result<SigningKey, String> {
243    let hex_str =
244        std::fs::read_to_string(path).map_err(|e| format!("Failed to read private key: {e}"))?;
245    let bytes =
246        hex::decode(hex_str.trim()).map_err(|e| format!("Invalid hex in private key: {e}"))?;
247    let key_bytes: [u8; 32] = bytes
248        .try_into()
249        .map_err(|_| "Private key must be exactly 32 bytes".to_string())?;
250    Ok(SigningKey::from_bytes(&key_bytes))
251}
252
253/// v0.49.3: public key-loading and primitive-signing helpers so the
254/// hub (and any other downstream binary) can sign small JSON payloads
255/// — e.g., the `/.well-known/vela` manifest — without needing direct
256/// access to the ed25519_dalek dep or to the SigningKey type.
257
258/// Load a hex-encoded Ed25519 signing key from disk.
259///
260/// Same on-disk format `vela sign generate-keypair` writes.
261pub fn load_signing_key_from_path(path: &Path) -> Result<SigningKey, String> {
262    load_signing_key(path)
263}
264
265/// Sign arbitrary bytes with the given key. Returns the 64-byte
266/// signature.
267pub fn sign_bytes(signing_key: &SigningKey, bytes: &[u8]) -> [u8; 64] {
268    signing_key.sign(bytes).to_bytes()
269}
270
271/// Hex-encoded Ed25519 public key (64 chars) for the given signing key.
272pub fn pubkey_hex(signing_key: &SigningKey) -> String {
273    hex::encode(signing_key.verifying_key().to_bytes())
274}
275
276/// Load a verifying key from a hex-encoded file.
277fn load_verifying_key(path: &Path) -> Result<VerifyingKey, String> {
278    let hex_str =
279        std::fs::read_to_string(path).map_err(|e| format!("Failed to read public key: {e}"))?;
280    parse_verifying_key(hex_str.trim())
281}
282
283/// Parse a verifying key from a hex string.
284fn parse_verifying_key(hex_str: &str) -> Result<VerifyingKey, String> {
285    let bytes = hex::decode(hex_str).map_err(|e| format!("Invalid hex in public key: {e}"))?;
286    let key_bytes: [u8; 32] = bytes
287        .try_into()
288        .map_err(|_| "Public key must be exactly 32 bytes".to_string())?;
289    VerifyingKey::from_bytes(&key_bytes).map_err(|e| format!("Invalid public key: {e}"))
290}
291
292/// Sign a single finding bundle, producing a SignedEnvelope.
293pub fn sign_finding(
294    finding: &FindingBundle,
295    signing_key: &SigningKey,
296) -> Result<SignedEnvelope, String> {
297    let canonical = canonical_json(finding)?;
298    let signature = signing_key.sign(canonical.as_bytes());
299    let public_key = signing_key.verifying_key();
300
301    Ok(SignedEnvelope {
302        finding_id: finding.id.clone(),
303        signature: hex::encode(signature.to_bytes()),
304        public_key: hex::encode(public_key.to_bytes()),
305        signed_at: Utc::now().to_rfc3339(),
306        algorithm: "ed25519".to_string(),
307    })
308}
309
310/// Verify a signed envelope against a finding bundle.
311pub fn verify_finding(finding: &FindingBundle, envelope: &SignedEnvelope) -> Result<bool, String> {
312    if finding.id != envelope.finding_id {
313        return Ok(false);
314    }
315
316    let verifying_key = parse_verifying_key(&envelope.public_key)?;
317    let sig_bytes =
318        hex::decode(&envelope.signature).map_err(|e| format!("Invalid signature hex: {e}"))?;
319    let signature = ed25519_dalek::Signature::from_bytes(
320        &sig_bytes
321            .try_into()
322            .map_err(|_| "Signature must be 64 bytes")?,
323    );
324
325    let canonical = canonical_json(finding)?;
326    Ok(verifying_key
327        .verify(canonical.as_bytes(), &signature)
328        .is_ok())
329}
330
331/// Verify a finding against a specific public key (hex-encoded).
332#[allow(dead_code)]
333pub fn verify_finding_with_pubkey(
334    finding: &FindingBundle,
335    envelope: &SignedEnvelope,
336    expected_pubkey: &str,
337) -> Result<bool, String> {
338    if envelope.public_key != expected_pubkey {
339        return Ok(false);
340    }
341    verify_finding(finding, envelope)
342}
343
344// ── Event signing (Phase M, v0.4) ────────────────────────────────────
345
346/// Compute the canonical signing bytes for a `StateEvent`. The `signature`
347/// field is excluded from the preimage (you can't sign over your own
348/// signature). The same canonical-JSON rule that derives `vev_…` is reused.
349///
350/// A second implementation must produce byte-identical signing bytes
351/// for the same event content; the verification rule depends on it.
352pub fn event_signing_bytes(event: &crate::events::StateEvent) -> Result<Vec<u8>, String> {
353    use serde_json::json;
354    let preimage = json!({
355        "schema": event.schema,
356        "id": event.id,
357        "kind": event.kind,
358        "target": event.target,
359        "actor": event.actor,
360        "timestamp": event.timestamp,
361        "reason": event.reason,
362        "before_hash": event.before_hash,
363        "after_hash": event.after_hash,
364        "payload": event.payload,
365        "caveats": event.caveats,
366    });
367    crate::canonical::to_canonical_bytes(&preimage)
368}
369
370/// Sign a canonical event with an Ed25519 private key, returning a
371/// hex-encoded signature suitable for `event.signature`.
372pub fn sign_event(
373    event: &crate::events::StateEvent,
374    signing_key: &SigningKey,
375) -> Result<String, String> {
376    let bytes = event_signing_bytes(event)?;
377    let signature = signing_key.sign(&bytes);
378    Ok(hex::encode(signature.to_bytes()))
379}
380
381/// Verify that `event.signature` is a valid Ed25519 signature over the
382/// canonical signing bytes of `event`, produced by the holder of the
383/// private key matching `expected_pubkey_hex`.
384pub fn verify_event_signature(
385    event: &crate::events::StateEvent,
386    expected_pubkey_hex: &str,
387) -> Result<bool, String> {
388    let signature_hex = event
389        .signature
390        .as_deref()
391        .ok_or_else(|| format!("event {} has no signature field", event.id))?;
392    let verifying_key = parse_verifying_key(expected_pubkey_hex)?;
393    let sig_bytes =
394        hex::decode(signature_hex).map_err(|e| format!("invalid signature hex: {e}"))?;
395    let signature = ed25519_dalek::Signature::from_bytes(
396        &sig_bytes
397            .try_into()
398            .map_err(|_| "Signature must be 64 bytes")?,
399    );
400    let bytes = event_signing_bytes(event)?;
401    Ok(verifying_key.verify(&bytes, &signature).is_ok())
402}
403
404// ── Proposal signing (Phase Q-w, v0.5) ───────────────────────────────
405
406/// Compute the canonical signing bytes for a `StateProposal`. The
407/// `signature` (held externally on the wire) is excluded from the
408/// preimage. Same canonical-JSON discipline as `event_signing_bytes`.
409///
410/// The proposal `id` is included, which deterministically pins the
411/// content (since `vpr_…` is content-addressed under Phase P).
412pub fn proposal_signing_bytes(
413    proposal: &crate::proposals::StateProposal,
414) -> Result<Vec<u8>, String> {
415    use serde_json::json;
416    let preimage = json!({
417        "schema": proposal.schema,
418        "id": proposal.id,
419        "kind": proposal.kind,
420        "target": proposal.target,
421        "actor": proposal.actor,
422        "created_at": proposal.created_at,
423        "reason": proposal.reason,
424        "payload": proposal.payload,
425        "source_refs": proposal.source_refs,
426        "caveats": proposal.caveats,
427    });
428    crate::canonical::to_canonical_bytes(&preimage)
429}
430
431/// Sign a proposal with an Ed25519 private key, returning a hex-encoded
432/// signature suitable for transport on a write API.
433pub fn sign_proposal(
434    proposal: &crate::proposals::StateProposal,
435    signing_key: &SigningKey,
436) -> Result<String, String> {
437    let bytes = proposal_signing_bytes(proposal)?;
438    Ok(hex::encode(signing_key.sign(&bytes).to_bytes()))
439}
440
441/// Verify a hex-encoded Ed25519 signature against the canonical signing
442/// bytes of `proposal`, using `expected_pubkey_hex` as the verifying key.
443pub fn verify_proposal_signature(
444    proposal: &crate::proposals::StateProposal,
445    signature_hex: &str,
446    expected_pubkey_hex: &str,
447) -> Result<bool, String> {
448    let verifying_key = parse_verifying_key(expected_pubkey_hex)?;
449    let sig_bytes =
450        hex::decode(signature_hex).map_err(|e| format!("invalid signature hex: {e}"))?;
451    let signature = ed25519_dalek::Signature::from_bytes(
452        &sig_bytes
453            .try_into()
454            .map_err(|_| "Signature must be 64 bytes")?,
455    );
456    let bytes = proposal_signing_bytes(proposal)?;
457    Ok(verifying_key.verify(&bytes, &signature).is_ok())
458}
459
460/// Generic signature verifier for action-on-canonical-bytes: verify
461/// `signature_hex` is a valid Ed25519 signature over `signing_bytes`,
462/// produced by the holder of `expected_pubkey_hex`. Used by write
463/// actions that don't sign over a full proposal/event struct (e.g.,
464/// accept/reject decisions).
465pub fn verify_action_signature(
466    signing_bytes: &[u8],
467    signature_hex: &str,
468    expected_pubkey_hex: &str,
469) -> Result<bool, String> {
470    let verifying_key = parse_verifying_key(expected_pubkey_hex)?;
471    let sig_bytes =
472        hex::decode(signature_hex).map_err(|e| format!("invalid signature hex: {e}"))?;
473    let signature = ed25519_dalek::Signature::from_bytes(
474        &sig_bytes
475            .try_into()
476            .map_err(|_| "Signature must be 64 bytes")?,
477    );
478    Ok(verifying_key.verify(signing_bytes, &signature).is_ok())
479}
480
481// ── Project-level operations ────────────────────────────────────────
482
483/// Sign all findings in a frontier that are not yet signed BY THIS KEY.
484/// Returns the number of newly signed findings.
485///
486/// v0.37: dedupe is now `(finding_id, public_key)`, not `finding_id`
487/// alone. Prior versions stopped at the first signature on a finding;
488/// that prevented multi-actor co-signing. Different actors with
489/// different keys can now each contribute a `SignedEnvelope` to the
490/// same finding. Re-running `vela sign apply` with the same key is
491/// still idempotent.
492pub fn sign_frontier(frontier_path: &Path, private_key_path: &Path) -> Result<usize, String> {
493    let mut frontier: Project = repo::load_from_path(frontier_path)?;
494
495    let signing_key = load_signing_key(private_key_path)?;
496    let our_pubkey_hex = hex::encode(signing_key.verifying_key().to_bytes());
497
498    let mut signed_count = 0usize;
499
500    // Already signed by THIS key and still valid for current bytes.
501    // If a finding changed after an earlier signature, drop the stale
502    // same-key envelope so this run can refresh it. Other actors'
503    // signatures stay.
504    let finding_by_id = frontier
505        .findings
506        .iter()
507        .map(|finding| (finding.id.as_str(), finding))
508        .collect::<std::collections::HashMap<_, _>>();
509    let mut already_signed_by_us = std::collections::HashSet::new();
510    let mut stale_signed_by_us = std::collections::HashSet::new();
511    for envelope in &frontier.signatures {
512        if envelope.public_key != our_pubkey_hex {
513            continue;
514        }
515        let valid = finding_by_id
516            .get(envelope.finding_id.as_str())
517            .and_then(|finding| verify_finding(finding, envelope).ok())
518            .unwrap_or(false);
519        if valid {
520            already_signed_by_us.insert(envelope.finding_id.clone());
521        } else {
522            stale_signed_by_us.insert(envelope.finding_id.clone());
523        }
524    }
525    if !stale_signed_by_us.is_empty() {
526        frontier.signatures.retain(|envelope| {
527            envelope.public_key != our_pubkey_hex
528                || !stale_signed_by_us.contains(&envelope.finding_id)
529        });
530        already_signed_by_us.retain(|finding_id| !stale_signed_by_us.contains(finding_id));
531    }
532
533    for finding in &frontier.findings {
534        if already_signed_by_us.contains(&finding.id) {
535            continue;
536        }
537        let envelope = sign_finding(finding, &signing_key)?;
538        frontier.signatures.push(envelope);
539        signed_count += 1;
540    }
541
542    let actor_ids_for_key: std::collections::HashSet<String> = frontier
543        .actors
544        .iter()
545        .filter(|actor| actor.public_key == our_pubkey_hex)
546        .map(|actor| actor.id.clone())
547        .collect();
548    if !actor_ids_for_key.is_empty() {
549        for event in &mut frontier.events {
550            if event.signature.is_some()
551                || event.actor.r#type != "human"
552                || !actor_ids_for_key.contains(&event.actor.id)
553            {
554                continue;
555            }
556            event.signature = Some(sign_event(event, &signing_key)?);
557            signed_count += 1;
558        }
559    }
560
561    // v0.37: refresh `jointly_accepted` flags after multi-sig writes.
562    refresh_jointly_accepted(&mut frontier);
563
564    repo::save_to_path(frontier_path, &frontier)?;
565
566    Ok(signed_count)
567}
568
569// ── Multi-sig helpers (v0.37) ────────────────────────────────────────
570
571/// Hex-encoded public keys of every actor whose `SignedEnvelope`
572/// targeting `finding_id` cryptographically verifies against the
573/// finding's canonical bytes. Duplicate signatures from the same key
574/// are counted once. Returns an empty Vec if the finding doesn't exist.
575#[must_use]
576pub fn signers_for(project: &Project, finding_id: &str) -> Vec<String> {
577    let Some(finding) = project.findings.iter().find(|f| f.id == finding_id) else {
578        return Vec::new();
579    };
580    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
581    for env in &project.signatures {
582        if env.finding_id != finding_id {
583            continue;
584        }
585        if seen.contains(&env.public_key) {
586            continue;
587        }
588        if let Ok(true) = verify_finding(finding, env) {
589            seen.insert(env.public_key.clone());
590        }
591    }
592    seen.into_iter().collect()
593}
594
595/// Number of unique valid signers on `finding_id`.
596#[must_use]
597pub fn valid_signature_count(project: &Project, finding_id: &str) -> usize {
598    signers_for(project, finding_id).len()
599}
600
601/// True iff `flags.signature_threshold` is `Some(k)` and `k` distinct
602/// valid signatures are present. `None` threshold means single-sig
603/// semantics — never reports threshold-met.
604#[must_use]
605pub fn threshold_met(project: &Project, finding_id: &str) -> bool {
606    let Some(finding) = project.findings.iter().find(|f| f.id == finding_id) else {
607        return false;
608    };
609    let Some(threshold) = finding.flags.signature_threshold else {
610        return false;
611    };
612    valid_signature_count(project, finding_id) >= threshold as usize
613}
614
615/// Walk every finding and (re)set `flags.jointly_accepted` to match
616/// the current state of `signature_threshold` and the multi-sig
617/// envelope set. Idempotent. Called from `sign_frontier` and the
618/// verify path so the flag never drifts from the underlying truth.
619pub fn refresh_jointly_accepted(project: &mut Project) {
620    // First snapshot the truth without holding a mutable borrow on findings.
621    let truth: std::collections::HashMap<String, bool> = project
622        .findings
623        .iter()
624        .map(|f| (f.id.clone(), threshold_met(project, &f.id)))
625        .collect();
626    for f in &mut project.findings {
627        f.flags.jointly_accepted = truth.get(&f.id).copied().unwrap_or(false);
628    }
629}
630
631/// Verify all signatures in a frontier. Optionally filter by a specific public key.
632pub fn verify_frontier(
633    frontier_path: &Path,
634    pubkey_path: Option<&Path>,
635) -> Result<VerifyReport, String> {
636    let frontier: Project = repo::load_from_path(frontier_path)?;
637
638    verify_frontier_data(&frontier, pubkey_path)
639}
640
641/// Verify all signatures in an in-memory frontier.
642pub fn verify_frontier_data(
643    frontier: &Project,
644    pubkey_path: Option<&Path>,
645) -> Result<VerifyReport, String> {
646    let expected_pubkey = match pubkey_path {
647        Some(path) => {
648            let key = load_verifying_key(path)?;
649            Some(hex::encode(key.to_bytes()))
650        }
651        None => None,
652    };
653
654    // Index findings by ID for fast lookup.
655    let finding_map: std::collections::HashMap<&str, &FindingBundle> = frontier
656        .findings
657        .iter()
658        .map(|f| (f.id.as_str(), f))
659        .collect();
660
661    // v0.104: count every signature individually, not one envelope per
662    // finding. Pre-v0.104 the verify path built a sig_map keyed by
663    // finding_id which collapsed multi-sig envelopes to whichever one
664    // happened to land last in the HashMap, so multi-sig frontiers
665    // under-reported `valid` and never showed two distinct signers
666    // even when both cryptographically validated.
667    let mut valid = 0usize;
668    let mut invalid = 0usize;
669    let mut signers: std::collections::HashSet<String> = std::collections::HashSet::new();
670    let mut findings_with_signature: std::collections::HashSet<&str> =
671        std::collections::HashSet::new();
672
673    for envelope in &frontier.signatures {
674        if let Some(ref expected) = expected_pubkey
675            && &envelope.public_key != expected
676        {
677            invalid += 1;
678            findings_with_signature.insert(envelope.finding_id.as_str());
679            continue;
680        }
681        let Some(finding) = finding_map.get(envelope.finding_id.as_str()) else {
682            invalid += 1;
683            continue;
684        };
685        findings_with_signature.insert(envelope.finding_id.as_str());
686        match verify_finding(finding, envelope) {
687            Ok(true) => {
688                valid += 1;
689                signers.insert(envelope.public_key.clone());
690            }
691            _ => {
692                invalid += 1;
693            }
694        }
695    }
696
697    let unsigned = frontier
698        .findings
699        .iter()
700        .filter(|f| !findings_with_signature.contains(f.id.as_str()))
701        .count();
702
703    // v0.37: count threshold flags + verified joint-accept state.
704    let mut findings_with_threshold = 0usize;
705    let mut jointly_accepted = 0usize;
706    for f in &frontier.findings {
707        if f.flags.signature_threshold.is_some() {
708            findings_with_threshold += 1;
709            if threshold_met(frontier, &f.id) {
710                jointly_accepted += 1;
711            }
712        }
713    }
714
715    Ok(VerifyReport {
716        total_findings: frontier.findings.len(),
717        signed: valid + invalid,
718        unsigned,
719        valid,
720        invalid,
721        signers: signers.into_iter().collect(),
722        findings_with_threshold,
723        jointly_accepted,
724    })
725}
726
727// ── Tests ────────────────────────────────────────────────────────────
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732    use crate::bundle::*;
733
734    fn sample_finding() -> FindingBundle {
735        FindingBundle::new(
736            Assertion {
737                text: "NLRP3 activates IL-1B".into(),
738                assertion_type: "mechanism".into(),
739                entities: vec![Entity {
740                    name: "NLRP3".into(),
741                    entity_type: "protein".into(),
742                    identifiers: serde_json::Map::new(),
743                    canonical_id: None,
744                    candidates: vec![],
745                    aliases: vec![],
746                    resolution_provenance: None,
747                    resolution_confidence: 1.0,
748                    resolution_method: None,
749                    species_context: None,
750                    needs_review: false,
751                }],
752                relation: Some("activates".into()),
753                direction: Some("positive".into()),
754                causal_claim: None,
755                causal_evidence_grade: None,
756            },
757            Evidence {
758                evidence_type: "experimental".into(),
759                model_system: "mouse".into(),
760                species: Some("Mus musculus".into()),
761                method: "Western blot".into(),
762                sample_size: Some("n=30".into()),
763                effect_size: None,
764                p_value: Some("p<0.05".into()),
765                replicated: true,
766                replication_count: Some(3),
767                evidence_spans: vec![],
768            },
769            Conditions {
770                text: "In vitro, mouse microglia".into(),
771                species_verified: vec!["Mus musculus".into()],
772                species_unverified: vec![],
773                in_vitro: true,
774                in_vivo: false,
775                human_data: false,
776                clinical_trial: false,
777                concentration_range: None,
778                duration: None,
779                age_group: None,
780                cell_type: Some("microglia".into()),
781            },
782            Confidence::raw(0.85, "Experimental with replication", 0.9),
783            Provenance {
784                source_type: "published_paper".into(),
785                doi: Some("10.1234/test".into()),
786                pmid: None,
787                pmc: None,
788                openalex_id: None,
789                url: None,
790                title: "Test Paper".into(),
791                authors: vec![Author {
792                    name: "Smith J".into(),
793                    orcid: None,
794                }],
795                year: Some(2024),
796                journal: Some("Nature".into()),
797                license: None,
798                publisher: None,
799                funders: vec![],
800                extraction: Extraction::default(),
801                review: None,
802                citation_count: Some(100),
803            },
804            Flags {
805                gap: false,
806                negative_space: false,
807                contested: false,
808                retracted: false,
809                declining: false,
810                gravity_well: false,
811                review_state: None,
812                superseded: false,
813                signature_threshold: None,
814                jointly_accepted: false,
815            },
816        )
817    }
818
819    fn test_keypair() -> SigningKey {
820        use rand::rngs::OsRng;
821        SigningKey::generate(&mut OsRng)
822    }
823
824    #[test]
825    fn keygen_produces_valid_files() {
826        let dir = std::env::temp_dir().join("vela_test_keygen");
827        let _ = std::fs::remove_dir_all(&dir);
828
829        let pubkey = generate_keypair(&dir).unwrap();
830        assert_eq!(pubkey.len(), 64); // 32 bytes hex-encoded
831
832        let private_hex = std::fs::read_to_string(dir.join("private.key")).unwrap();
833        let public_hex = std::fs::read_to_string(dir.join("public.key")).unwrap();
834        assert_eq!(private_hex.len(), 64);
835        assert_eq!(public_hex, pubkey);
836
837        let _ = std::fs::remove_dir_all(&dir);
838    }
839
840    #[test]
841    fn sign_and_verify_roundtrip() {
842        let finding = sample_finding();
843        let key = test_keypair();
844
845        let envelope = sign_finding(&finding, &key).unwrap();
846        assert_eq!(envelope.finding_id, finding.id);
847        assert_eq!(envelope.algorithm, "ed25519");
848        assert_eq!(envelope.signature.len(), 128); // 64 bytes hex-encoded
849
850        let valid = verify_finding(&finding, &envelope).unwrap();
851        assert!(valid, "Signature should verify against original finding");
852    }
853
854    #[test]
855    fn tampered_finding_fails_verification() {
856        let finding = sample_finding();
857        let key = test_keypair();
858        let envelope = sign_finding(&finding, &key).unwrap();
859
860        // Tamper with the finding
861        let mut tampered = finding.clone();
862        tampered.assertion.text = "Tampered assertion text".into();
863
864        let valid = verify_finding(&tampered, &envelope).unwrap();
865        assert!(!valid, "Tampered finding should fail verification");
866    }
867
868    #[test]
869    fn sign_frontier_replaces_stale_same_key_signature() {
870        let dir = tempfile::tempdir().unwrap();
871        let frontier_path = dir.path().join("frontier.json");
872        let private_key_path = dir.path().join("private.key");
873        let key = test_keypair();
874        std::fs::write(&private_key_path, hex::encode(key.to_bytes())).unwrap();
875
876        let mut finding = sample_finding();
877        let stale_envelope = sign_finding(&finding, &key).unwrap();
878        finding.assertion.text = "NLRP3 activates IL-1B under revised scope".into();
879        let mut frontier = empty_project(vec![finding], vec![stale_envelope]);
880        crate::repo::save_to_path(&frontier_path, &frontier).unwrap();
881
882        let signed = sign_frontier(&frontier_path, &private_key_path).unwrap();
883        assert_eq!(signed, 1);
884
885        frontier = crate::repo::load_from_path(&frontier_path).unwrap();
886        let report = verify_frontier_data(&frontier, None).unwrap();
887        assert_eq!(report.valid, 1);
888        assert_eq!(report.invalid, 0);
889        assert_eq!(frontier.signatures.len(), 1);
890    }
891
892    #[test]
893    fn wrong_key_fails_verification() {
894        let finding = sample_finding();
895        let key1 = test_keypair();
896        let key2 = test_keypair();
897
898        let envelope = sign_finding(&finding, &key1).unwrap();
899        let pubkey2_hex = hex::encode(key2.verifying_key().to_bytes());
900
901        let valid = verify_finding_with_pubkey(&finding, &envelope, &pubkey2_hex).unwrap();
902        assert!(!valid, "Wrong public key should fail verification");
903    }
904
905    #[test]
906    fn canonical_json_is_deterministic() {
907        let finding = sample_finding();
908        let json1 = canonical_json(&finding).unwrap();
909        let json2 = canonical_json(&finding).unwrap();
910        assert_eq!(json1, json2, "Canonical JSON must be deterministic");
911    }
912
913    #[test]
914    fn registered_actor_signed_event_roundtrip() {
915        // Phase M: a registered actor's event must sign-and-verify
916        // against its registered pubkey via `event_signing_bytes`. This
917        // is the load-bearing claim for the v0.4 strict-mode gate.
918        use crate::events::{
919            EVENT_SCHEMA, NULL_HASH, StateActor, StateEvent, StateTarget, compute_event_id,
920        };
921
922        let key = test_keypair();
923        let pubkey_hex = hex::encode(key.verifying_key().to_bytes());
924
925        let mut event = StateEvent {
926            schema: EVENT_SCHEMA.to_string(),
927            id: String::new(),
928            kind: "finding.reviewed".to_string(),
929            target: StateTarget {
930                r#type: "finding".to_string(),
931                id: "vf_test".to_string(),
932            },
933            actor: StateActor {
934                id: "reviewer:registered".to_string(),
935                r#type: "human".to_string(),
936            },
937            timestamp: "2026-04-25T00:00:00Z".to_string(),
938            reason: "phase-m round-trip test".to_string(),
939            before_hash: NULL_HASH.to_string(),
940            after_hash: "sha256:abc".to_string(),
941            payload: serde_json::json!({"status": "accepted", "proposal_id": "vpr_test"}),
942            caveats: vec![],
943            signature: None,
944            schema_artifact_id: None,
945        };
946        event.id = compute_event_id(&event);
947        event.signature = Some(sign_event(&event, &key).unwrap());
948
949        // Verifies against the registered pubkey.
950        assert!(verify_event_signature(&event, &pubkey_hex).unwrap());
951
952        // Tampering with the reason invalidates the signature.
953        let mut tampered = event.clone();
954        tampered.reason = "different reason".to_string();
955        assert!(!verify_event_signature(&tampered, &pubkey_hex).unwrap());
956    }
957
958    #[test]
959    fn verify_frontier_data_reports_correctly() {
960        let f1 = sample_finding();
961        let mut f2 = sample_finding();
962        f2.id = "vf_other_id_12345".into();
963        f2.assertion.text = "Different finding".into();
964
965        let key = test_keypair();
966        let env1 = sign_finding(&f1, &key).unwrap();
967        // Leave f2 unsigned
968
969        let frontier = Project {
970            vela_version: "0.1.0".into(),
971            schema: "test".into(),
972            frontier_id: None,
973            project: crate::project::ProjectMeta {
974                name: "test".into(),
975                description: "test".into(),
976                compiled_at: "2024-01-01T00:00:00Z".into(),
977                compiler: "vela/0.2.0".into(),
978                papers_processed: 0,
979                errors: 0,
980                dependencies: Vec::new(),
981            },
982            stats: crate::project::ProjectStats {
983                findings: 2,
984                links: 0,
985                replicated: 0,
986                unreplicated: 2,
987                avg_confidence: 0.85,
988                gaps: 0,
989                negative_space: 0,
990                contested: 0,
991                categories: std::collections::HashMap::new(),
992                link_types: std::collections::HashMap::new(),
993                human_reviewed: 0,
994                review_event_count: 0,
995                confidence_update_count: 0,
996                event_count: 0,
997                source_count: 0,
998                evidence_atom_count: 0,
999                condition_record_count: 0,
1000                proposal_count: 0,
1001                confidence_distribution: crate::project::ConfidenceDistribution {
1002                    high_gt_80: 2,
1003                    medium_60_80: 0,
1004                    low_lt_60: 0,
1005                },
1006            },
1007            findings: vec![f1, f2],
1008            sources: vec![],
1009            evidence_atoms: vec![],
1010            condition_records: vec![],
1011            review_events: vec![],
1012            confidence_updates: vec![],
1013            events: vec![],
1014            proposals: vec![],
1015            proof_state: Default::default(),
1016            signatures: vec![env1],
1017            actors: vec![],
1018            replications: vec![],
1019            datasets: vec![],
1020            code_artifacts: vec![],
1021            artifacts: vec![],
1022            predictions: vec![],
1023            resolutions: vec![],
1024            peers: vec![],
1025            negative_results: vec![],
1026            trajectories: vec![],
1027        };
1028
1029        let report = verify_frontier_data(&frontier, None).unwrap();
1030        assert_eq!(report.total_findings, 2);
1031        assert_eq!(report.signed, 1);
1032        assert_eq!(report.unsigned, 1);
1033        assert_eq!(report.valid, 1);
1034        assert_eq!(report.invalid, 0);
1035        assert_eq!(report.signers.len(), 1);
1036    }
1037
1038    // ── v0.37 Multi-sig tests ────────────────────────────────────────
1039
1040    fn empty_project(findings: Vec<FindingBundle>, signatures: Vec<SignedEnvelope>) -> Project {
1041        Project {
1042            vela_version: "0.37.0".into(),
1043            schema: "test".into(),
1044            frontier_id: None,
1045            project: crate::project::ProjectMeta {
1046                name: "test".into(),
1047                description: "test".into(),
1048                compiled_at: "2026-04-27T00:00:00Z".into(),
1049                compiler: "vela/0.37.0".into(),
1050                papers_processed: 0,
1051                errors: 0,
1052                dependencies: Vec::new(),
1053            },
1054            stats: crate::project::ProjectStats::default(),
1055            findings,
1056            sources: vec![],
1057            evidence_atoms: vec![],
1058            condition_records: vec![],
1059            review_events: vec![],
1060            confidence_updates: vec![],
1061            events: vec![],
1062            proposals: vec![],
1063            proof_state: Default::default(),
1064            signatures,
1065            actors: vec![],
1066            replications: vec![],
1067            datasets: vec![],
1068            code_artifacts: vec![],
1069            artifacts: vec![],
1070            predictions: vec![],
1071            resolutions: vec![],
1072            peers: vec![],
1073            negative_results: vec![],
1074            trajectories: vec![],
1075        }
1076    }
1077
1078    #[test]
1079    fn signers_for_dedupes_by_pubkey() {
1080        let mut f = sample_finding();
1081        f.flags.signature_threshold = Some(2);
1082        let key1 = test_keypair();
1083        let key2 = test_keypair();
1084        let env1 = sign_finding(&f, &key1).unwrap();
1085        let env1_dup = sign_finding(&f, &key1).unwrap();
1086        let env2 = sign_finding(&f, &key2).unwrap();
1087        let project = empty_project(vec![f.clone()], vec![env1, env1_dup, env2]);
1088        let signers = signers_for(&project, &f.id);
1089        assert_eq!(signers.len(), 2, "duplicate pubkey must be counted once");
1090    }
1091
1092    #[test]
1093    fn threshold_met_requires_k_unique_signers() {
1094        let mut f = sample_finding();
1095        f.flags.signature_threshold = Some(2);
1096        let key1 = test_keypair();
1097        let env1 = sign_finding(&f, &key1).unwrap();
1098        let project_one = empty_project(vec![f.clone()], vec![env1.clone()]);
1099        assert!(!threshold_met(&project_one, &f.id), "1 of 2 not met");
1100
1101        let key2 = test_keypair();
1102        let env2 = sign_finding(&f, &key2).unwrap();
1103        let project_two = empty_project(vec![f.clone()], vec![env1, env2]);
1104        assert!(threshold_met(&project_two, &f.id), "2 of 2 met");
1105    }
1106
1107    #[test]
1108    fn threshold_none_reports_not_met() {
1109        let f = sample_finding();
1110        // signature_threshold defaults to None.
1111        let key = test_keypair();
1112        let env = sign_finding(&f, &key).unwrap();
1113        let project = empty_project(vec![f.clone()], vec![env]);
1114        assert!(
1115            !threshold_met(&project, &f.id),
1116            "no policy → never met (single-sig regime)"
1117        );
1118    }
1119
1120    #[test]
1121    fn refresh_jointly_accepted_sets_flag() {
1122        let mut f = sample_finding();
1123        f.flags.signature_threshold = Some(1);
1124        let key = test_keypair();
1125        let env = sign_finding(&f, &key).unwrap();
1126        let mut project = empty_project(vec![f.clone()], vec![env]);
1127        refresh_jointly_accepted(&mut project);
1128        assert!(project.findings[0].flags.jointly_accepted);
1129    }
1130
1131    #[test]
1132    fn invalid_signature_does_not_count_toward_threshold() {
1133        let mut f = sample_finding();
1134        f.flags.signature_threshold = Some(2);
1135        let key1 = test_keypair();
1136        let key2 = test_keypair();
1137        let env1 = sign_finding(&f, &key1).unwrap();
1138        let mut env2_tampered = sign_finding(&f, &key2).unwrap();
1139        // Replace signature bytes with garbage; key still claims to be key2.
1140        env2_tampered.signature = "00".repeat(64);
1141        let project = empty_project(vec![f.clone()], vec![env1, env2_tampered]);
1142        assert_eq!(valid_signature_count(&project, &f.id), 1);
1143        assert!(!threshold_met(&project, &f.id));
1144    }
1145
1146    #[test]
1147    fn verify_report_surfaces_threshold_counts() {
1148        let mut f = sample_finding();
1149        f.flags.signature_threshold = Some(1);
1150        let key = test_keypair();
1151        let env = sign_finding(&f, &key).unwrap();
1152        let project = empty_project(vec![f.clone()], vec![env]);
1153        let report = verify_frontier_data(&project, None).unwrap();
1154        assert_eq!(report.findings_with_threshold, 1);
1155        assert_eq!(report.jointly_accepted, 1);
1156    }
1157
1158    // ── v0.43 ORCID validation ───────────────────────────────────────
1159
1160    #[test]
1161    fn validate_orcid_accepts_canonical_form() {
1162        assert_eq!(
1163            validate_orcid("0000-0001-2345-6789").unwrap(),
1164            "0000-0001-2345-6789"
1165        );
1166    }
1167
1168    #[test]
1169    fn validate_orcid_accepts_check_digit_x() {
1170        assert_eq!(
1171            validate_orcid("0000-0001-5109-393X").unwrap(),
1172            "0000-0001-5109-393X"
1173        );
1174    }
1175
1176    #[test]
1177    fn validate_orcid_strips_url_prefix() {
1178        assert_eq!(
1179            validate_orcid("https://orcid.org/0000-0001-2345-6789").unwrap(),
1180            "0000-0001-2345-6789"
1181        );
1182    }
1183
1184    #[test]
1185    fn validate_orcid_strips_orcid_prefix() {
1186        assert_eq!(
1187            validate_orcid("orcid:0000-0001-2345-6789").unwrap(),
1188            "0000-0001-2345-6789"
1189        );
1190    }
1191
1192    #[test]
1193    fn validate_orcid_rejects_short() {
1194        assert!(validate_orcid("0000-0001").is_err());
1195    }
1196
1197    #[test]
1198    fn validate_orcid_rejects_letters_in_non_check_position() {
1199        assert!(validate_orcid("0000-A001-2345-6789").is_err());
1200    }
1201
1202    #[test]
1203    fn validate_orcid_rejects_x_in_first_three_groups() {
1204        assert!(validate_orcid("000X-0001-2345-6789").is_err());
1205    }
1206
1207    #[test]
1208    fn validate_orcid_rejects_extra_groups() {
1209        assert!(validate_orcid("0000-0001-2345-6789-9999").is_err());
1210    }
1211}