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