Skip to main content

harn_cli/
skill_provenance.rs

1use harn_kernel::pure::sha256_hex;
2use std::collections::BTreeSet;
3use std::ffi::OsString;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::time::Duration;
7
8use base64::Engine;
9use ed25519_dalek::pkcs8::{
10    spki::der::pem::LineEnding, DecodePrivateKey, DecodePublicKey, EncodePrivateKey,
11    EncodePublicKey,
12};
13use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use time::format_description::well_known::Rfc3339;
17use url::Url;
18
19use crate::{net, package::load_skills_config};
20
21pub(crate) const SIGNER_REGISTRY_URL_ENV: &str = "HARN_SKILL_SIGNER_REGISTRY_URL";
22const SIG_SCHEMA: &str = "harn-skill-sig/v2";
23
24#[derive(Debug, Clone)]
25pub(crate) struct GeneratedKeypair {
26    pub private_key_path: PathBuf,
27    pub public_key_path: PathBuf,
28    pub fingerprint: String,
29}
30
31#[derive(Debug, Clone)]
32pub(crate) struct SignedSkill {
33    pub signature_path: PathBuf,
34    pub signer_fingerprint: String,
35    pub skill_sha256: String,
36}
37
38#[derive(Debug, Clone)]
39pub(crate) struct EndorsedSkill {
40    pub signature_path: PathBuf,
41    pub endorser_fingerprint: String,
42    pub skill_sha256: String,
43}
44
45#[derive(Debug, Clone, Default)]
46pub(crate) struct VerifyOptions {
47    pub registry_url: Option<String>,
48    pub allowed_signers: Vec<String>,
49    pub allowed_endorsers: Vec<String>,
50}
51
52#[derive(Debug, Clone, Default, Deserialize)]
53pub(crate) struct TrustPolicy {
54    #[serde(default, alias = "registry_url")]
55    pub signer_registry_url: Option<String>,
56    #[serde(default)]
57    pub trusted_signers: Vec<String>,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub(crate) enum VerificationStatus {
62    Verified,
63    MissingSignature,
64    InvalidSignature,
65    MissingSigner,
66    UntrustedSigner,
67    MissingEndorsement,
68}
69
70impl VerificationStatus {
71    pub(crate) fn as_str(self) -> &'static str {
72        match self {
73            VerificationStatus::Verified => "verified",
74            VerificationStatus::MissingSignature => "missing_signature",
75            VerificationStatus::InvalidSignature => "invalid_signature",
76            VerificationStatus::MissingSigner => "missing_signer",
77            VerificationStatus::UntrustedSigner => "untrusted_signer",
78            VerificationStatus::MissingEndorsement => "missing_endorsement",
79        }
80    }
81}
82
83#[derive(Debug, Clone)]
84pub(crate) struct EndorsementReport {
85    pub endorser_fingerprint: String,
86    pub signed_at: String,
87    pub trusted: bool,
88    pub status: VerificationStatus,
89    pub error: Option<String>,
90}
91
92#[derive(Debug, Clone)]
93pub(crate) struct VerificationReport {
94    pub skill_path: PathBuf,
95    pub signature_path: PathBuf,
96    pub skill_sha256: String,
97    pub signer_fingerprint: Option<String>,
98    pub signed_at: Option<String>,
99    pub endorsements: Vec<EndorsementReport>,
100    pub signed: bool,
101    pub trusted: bool,
102    pub status: VerificationStatus,
103    pub error: Option<String>,
104}
105
106impl VerificationReport {
107    pub(crate) fn is_verified(&self) -> bool {
108        self.status == VerificationStatus::Verified
109    }
110
111    pub(crate) fn human_summary(&self) -> String {
112        match &self.error {
113            Some(error) => error.clone(),
114            None => match self.status {
115                VerificationStatus::Verified => format!(
116                    "{} verified by {}",
117                    self.skill_path.display(),
118                    self.signer_fingerprint.clone().unwrap_or_default()
119                ),
120                VerificationStatus::MissingSignature => format!(
121                    "{} is missing {}",
122                    self.skill_path.display(),
123                    self.signature_path.display()
124                ),
125                VerificationStatus::InvalidSignature => {
126                    format!("{} has an invalid signature", self.skill_path.display())
127                }
128                VerificationStatus::MissingSigner => format!(
129                    "{} was signed by {}, but that signer is not installed locally and no registry resolved it",
130                    self.skill_path.display(),
131                    self.signer_fingerprint.clone().unwrap_or_default()
132                ),
133                VerificationStatus::UntrustedSigner => format!(
134                    "{} was signed by {}, but that signer is not trusted for this skill",
135                    self.skill_path.display(),
136                    self.signer_fingerprint.clone().unwrap_or_default()
137                ),
138                VerificationStatus::MissingEndorsement => format!(
139                    "{} is missing at least one trusted endorsement signature",
140                    self.skill_path.display()
141                ),
142            },
143        }
144    }
145}
146
147#[derive(Debug, Clone)]
148pub(crate) struct TrustedSignerRecord {
149    pub fingerprint: String,
150    pub path: PathBuf,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub(crate) struct SkillSignatureEnvelope {
155    pub schema: String,
156    pub signed_at: String,
157    pub signer_fingerprint: String,
158    pub ed25519_sig_base64: String,
159    pub skill_sha256: String,
160    #[serde(default)]
161    pub endorsements: Vec<SkillSignatureEndorsement>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub(crate) struct SkillSignatureEndorsement {
166    pub signed_at: String,
167    pub endorser_fingerprint: String,
168    pub ed25519_sig_base64: String,
169}
170
171pub(crate) fn generate_keypair(out: impl AsRef<Path>) -> Result<GeneratedKeypair, String> {
172    let private_key_path = out.as_ref().to_path_buf();
173    if let Some(parent) = private_key_path.parent() {
174        fs::create_dir_all(parent).map_err(|error| {
175            format!(
176                "failed to create private-key directory {}: {error}",
177                parent.display()
178            )
179        })?;
180    }
181    let public_key_path = append_suffix(&private_key_path, ".pub");
182
183    let seed: [u8; 32] = rand::random();
184    let signing_key = SigningKey::from_bytes(&seed);
185    let verifying_key = signing_key.verifying_key();
186    let private_pem = signing_key
187        .to_pkcs8_pem(LineEnding::LF)
188        .map_err(|error| format!("failed to encode private key as PEM: {error}"))?;
189    let public_pem = verifying_key
190        .to_public_key_pem(LineEnding::LF)
191        .map_err(|error| format!("failed to encode public key as PEM: {error}"))?;
192
193    fs::write(&private_key_path, private_pem.as_bytes()).map_err(|error| {
194        format!(
195            "failed to write private key {}: {error}",
196            private_key_path.display()
197        )
198    })?;
199    fs::write(&public_key_path, public_pem.as_bytes()).map_err(|error| {
200        format!(
201            "failed to write public key {}: {error}",
202            public_key_path.display()
203        )
204    })?;
205
206    Ok(GeneratedKeypair {
207        private_key_path,
208        public_key_path,
209        fingerprint: fingerprint_for_key(&verifying_key),
210    })
211}
212
213pub(crate) fn sign_skill(
214    skill_path: impl AsRef<Path>,
215    private_key_path: impl AsRef<Path>,
216) -> Result<SignedSkill, String> {
217    let skill_path = skill_path.as_ref();
218    let private_key_path = private_key_path.as_ref();
219    let skill_bytes = fs::read(skill_path)
220        .map_err(|error| format!("failed to read {}: {error}", skill_path.display()))?;
221    let signing_key = load_ed25519_signing_key(private_key_path)?;
222    let signature = signing_key.sign(&skill_bytes);
223    let signer_fingerprint = fingerprint_for_key(&signing_key.verifying_key());
224    let skill_sha256 = sha256_hex(&skill_bytes);
225    let signed_at = time::OffsetDateTime::now_utc()
226        .format(&Rfc3339)
227        .map_err(|error| format!("failed to format signed_at timestamp: {error}"))?;
228    let envelope = SkillSignatureEnvelope {
229        schema: SIG_SCHEMA.to_string(),
230        signed_at,
231        signer_fingerprint: signer_fingerprint.clone(),
232        ed25519_sig_base64: base64::engine::general_purpose::STANDARD.encode(signature.to_bytes()),
233        skill_sha256: skill_sha256.clone(),
234        endorsements: Vec::new(),
235    };
236    let signature_path = signature_path_for(skill_path);
237    let serialized = serde_json::to_string_pretty(&envelope)
238        .map_err(|error| format!("failed to serialize signature: {error}"))?;
239    fs::write(&signature_path, serialized.as_bytes()).map_err(|error| {
240        format!(
241            "failed to write signature {}: {error}",
242            signature_path.display()
243        )
244    })?;
245
246    Ok(SignedSkill {
247        signature_path,
248        signer_fingerprint,
249        skill_sha256,
250    })
251}
252
253pub(crate) fn endorse_skill(
254    skill_path: impl AsRef<Path>,
255    private_key_path: impl AsRef<Path>,
256) -> Result<EndorsedSkill, String> {
257    let skill_path = skill_path.as_ref();
258    let private_key_path = private_key_path.as_ref();
259    let skill_bytes = fs::read(skill_path)
260        .map_err(|error| format!("failed to read {}: {error}", skill_path.display()))?;
261    let skill_sha256 = sha256_hex(&skill_bytes);
262    let signature_path = signature_path_for(skill_path);
263    let mut envelope = read_signature_envelope(&signature_path)?;
264    if envelope.schema != SIG_SCHEMA {
265        return Err(format!(
266            "{} declares unsupported schema {}",
267            signature_path.display(),
268            envelope.schema
269        ));
270    }
271    if envelope.skill_sha256 != skill_sha256 {
272        return Err(format!(
273            "{} does not match the current contents of {}",
274            signature_path.display(),
275            skill_path.display()
276        ));
277    }
278
279    let signing_key = load_ed25519_signing_key(private_key_path)?;
280    let endorser_fingerprint = fingerprint_for_key(&signing_key.verifying_key());
281    if endorser_fingerprint == envelope.signer_fingerprint {
282        return Err(
283            "skill endorsements must be signed by a different key than the author signature"
284                .to_string(),
285        );
286    }
287    let signed_at = time::OffsetDateTime::now_utc()
288        .format(&Rfc3339)
289        .map_err(|error| format!("failed to format signed_at timestamp: {error}"))?;
290    let endorsement = SkillSignatureEndorsement {
291        signed_at,
292        endorser_fingerprint: endorser_fingerprint.clone(),
293        ed25519_sig_base64: base64::engine::general_purpose::STANDARD
294            .encode(signing_key.sign(&skill_bytes).to_bytes()),
295    };
296    envelope
297        .endorsements
298        .retain(|existing| existing.endorser_fingerprint != endorser_fingerprint);
299    envelope.endorsements.push(endorsement);
300    envelope
301        .endorsements
302        .sort_by(|left, right| left.endorser_fingerprint.cmp(&right.endorser_fingerprint));
303    let serialized = serde_json::to_string_pretty(&envelope)
304        .map_err(|error| format!("failed to serialize signature: {error}"))?;
305    fs::write(&signature_path, serialized.as_bytes()).map_err(|error| {
306        format!(
307            "failed to write signature {}: {error}",
308            signature_path.display()
309        )
310    })?;
311
312    Ok(EndorsedSkill {
313        signature_path,
314        endorser_fingerprint,
315        skill_sha256,
316    })
317}
318
319pub(crate) fn verify_skill(
320    skill_path: impl AsRef<Path>,
321    options: &VerifyOptions,
322) -> Result<VerificationReport, String> {
323    let skill_path = skill_path.as_ref();
324    let skill_bytes = fs::read(skill_path)
325        .map_err(|error| format!("failed to read {}: {error}", skill_path.display()))?;
326    let skill_sha256 = sha256_hex(&skill_bytes);
327    let signature_path = signature_path_for(skill_path);
328    let allowed_signers: BTreeSet<String> = options.allowed_signers.iter().cloned().collect();
329    let base_report = VerificationReport {
330        skill_path: skill_path.to_path_buf(),
331        signature_path: signature_path.clone(),
332        skill_sha256: skill_sha256.clone(),
333        signer_fingerprint: None,
334        signed_at: None,
335        endorsements: Vec::new(),
336        signed: false,
337        trusted: false,
338        status: VerificationStatus::MissingSignature,
339        error: None,
340    };
341
342    let signature_raw = match fs::read_to_string(&signature_path) {
343        Ok(raw) => raw,
344        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(base_report),
345        Err(error) => {
346            return Err(format!(
347                "failed to read signature {}: {error}",
348                signature_path.display()
349            ))
350        }
351    };
352    let envelope: SkillSignatureEnvelope = match serde_json::from_str(&signature_raw) {
353        Ok(envelope) => envelope,
354        Err(error) => {
355            return Ok(VerificationReport {
356                error: Some(format!(
357                    "{} is not valid {} JSON: {error}",
358                    signature_path.display(),
359                    SIG_SCHEMA
360                )),
361                status: VerificationStatus::InvalidSignature,
362                ..base_report
363            })
364        }
365    };
366    if envelope.schema != SIG_SCHEMA {
367        return Ok(VerificationReport {
368            signer_fingerprint: Some(envelope.signer_fingerprint),
369            status: VerificationStatus::InvalidSignature,
370            error: Some(format!(
371                "{} declares unsupported schema {}",
372                signature_path.display(),
373                envelope.schema
374            )),
375            ..base_report
376        });
377    }
378    if envelope.skill_sha256 != skill_sha256 {
379        return Ok(VerificationReport {
380            signer_fingerprint: Some(envelope.signer_fingerprint),
381            status: VerificationStatus::InvalidSignature,
382            error: Some(format!(
383                "{} does not match the current contents of {}",
384                signature_path.display(),
385                skill_path.display()
386            )),
387            ..base_report
388        });
389    }
390
391    let signer_fingerprint = envelope.signer_fingerprint.clone();
392    let base_report = VerificationReport {
393        signer_fingerprint: Some(signer_fingerprint.clone()),
394        signed_at: Some(envelope.signed_at.clone()),
395        signed: true,
396        ..base_report
397    };
398
399    let verifying_key =
400        match resolve_verifying_key(&signer_fingerprint, options.registry_url.as_deref())? {
401            Some(key) => key,
402            None => {
403                return Ok(VerificationReport {
404                    status: VerificationStatus::MissingSigner,
405                    error: Some(format!(
406                        "{} was signed by {}, but {} is not present in {}",
407                        skill_path.display(),
408                        signer_fingerprint,
409                        signer_fingerprint,
410                        trusted_signers_dir()?.display()
411                    )),
412                    ..base_report
413                })
414            }
415        };
416    let signature_bytes = match base64::engine::general_purpose::STANDARD
417        .decode(envelope.ed25519_sig_base64.as_bytes())
418    {
419        Ok(bytes) => bytes,
420        Err(error) => {
421            return Ok(VerificationReport {
422                status: VerificationStatus::InvalidSignature,
423                error: Some(format!("signature is not valid base64: {error}")),
424                ..base_report
425            })
426        }
427    };
428    let signature = match Signature::from_slice(&signature_bytes) {
429        Ok(signature) => signature,
430        Err(error) => {
431            return Ok(VerificationReport {
432                status: VerificationStatus::InvalidSignature,
433                error: Some(format!("signature is not valid Ed25519 bytes: {error}")),
434                ..base_report
435            })
436        }
437    };
438    if verifying_key.verify(&skill_bytes, &signature).is_err() {
439        return Ok(VerificationReport {
440            status: VerificationStatus::InvalidSignature,
441            error: Some(format!(
442                "{} failed Ed25519 verification for {}",
443                signature_path.display(),
444                skill_path.display()
445            )),
446            ..base_report
447        });
448    }
449    if !allowed_signers.is_empty() && !allowed_signers.contains(&signer_fingerprint) {
450        return Ok(VerificationReport {
451            status: VerificationStatus::UntrustedSigner,
452            error: Some(format!(
453                "{} was signed by {}, which is not in the skill's trusted_signers allowlist",
454                skill_path.display(),
455                signer_fingerprint
456            )),
457            ..base_report
458        });
459    }
460
461    let endorsement_reports = verify_endorsements(&skill_bytes, &envelope.endorsements, options)?;
462    if endorsement_reports.is_empty() {
463        return Ok(VerificationReport {
464            endorsements: endorsement_reports,
465            status: VerificationStatus::MissingEndorsement,
466            error: Some(format!(
467                "{} has no endorsement signatures; add at least one with `harn skill endorse`",
468                skill_path.display()
469            )),
470            ..base_report
471        });
472    }
473    if let Some(failed) = endorsement_reports
474        .iter()
475        .find(|endorsement| endorsement.status != VerificationStatus::Verified)
476    {
477        return Ok(VerificationReport {
478            endorsements: endorsement_reports.clone(),
479            status: failed.status,
480            error: failed.error.clone(),
481            ..base_report
482        });
483    }
484
485    Ok(VerificationReport {
486        endorsements: endorsement_reports,
487        trusted: true,
488        status: VerificationStatus::Verified,
489        ..base_report
490    })
491}
492
493fn verify_endorsements(
494    skill_bytes: &[u8],
495    endorsements: &[SkillSignatureEndorsement],
496    options: &VerifyOptions,
497) -> Result<Vec<EndorsementReport>, String> {
498    let allowed_endorsers: BTreeSet<String> = options.allowed_endorsers.iter().cloned().collect();
499    endorsements
500        .iter()
501        .map(|endorsement| {
502            let fingerprint = endorsement.endorser_fingerprint.clone();
503            let base_report = EndorsementReport {
504                endorser_fingerprint: fingerprint.clone(),
505                signed_at: endorsement.signed_at.clone(),
506                trusted: false,
507                status: VerificationStatus::InvalidSignature,
508                error: None,
509            };
510            let Some(verifying_key) =
511                resolve_verifying_key(&fingerprint, options.registry_url.as_deref())?
512            else {
513                return Ok(EndorsementReport {
514                    status: VerificationStatus::MissingSigner,
515                    error: Some(format!(
516                        "endorsement signer {fingerprint} is not installed locally and no registry resolved it"
517                    )),
518                    ..base_report
519                });
520            };
521            if !allowed_endorsers.is_empty() && !allowed_endorsers.contains(&fingerprint) {
522                return Ok(EndorsementReport {
523                    status: VerificationStatus::UntrustedSigner,
524                    error: Some(format!(
525                        "endorsement signer {fingerprint} is not in the skill's trusted_endorsers allowlist"
526                    )),
527                    ..base_report
528                });
529            }
530            let signature_bytes = match base64::engine::general_purpose::STANDARD
531                .decode(endorsement.ed25519_sig_base64.as_bytes())
532            {
533                Ok(bytes) => bytes,
534                Err(error) => {
535                    return Ok(EndorsementReport {
536                        error: Some(format!(
537                            "endorsement signature for {fingerprint} is not valid base64: {error}"
538                        )),
539                        ..base_report
540                    })
541                }
542            };
543            let signature = match Signature::from_slice(&signature_bytes) {
544                Ok(signature) => signature,
545                Err(error) => {
546                    return Ok(EndorsementReport {
547                        error: Some(format!(
548                            "endorsement signature for {fingerprint} is not valid Ed25519 bytes: {error}"
549                        )),
550                        ..base_report
551                    })
552                }
553            };
554            if verifying_key.verify(skill_bytes, &signature).is_err() {
555                return Ok(EndorsementReport {
556                    error: Some(format!(
557                        "endorsement signature for {fingerprint} failed Ed25519 verification"
558                    )),
559                    ..base_report
560                });
561            }
562            Ok(EndorsementReport {
563                trusted: true,
564                status: VerificationStatus::Verified,
565                ..base_report
566            })
567        })
568        .collect()
569}
570
571pub(crate) fn trust_add(from: &str) -> Result<TrustedSignerRecord, String> {
572    let verifying_key = verifying_key_from_source(from)?;
573    let fingerprint = fingerprint_for_key(&verifying_key);
574    let pem = verifying_key
575        .to_public_key_pem(LineEnding::LF)
576        .map_err(|error| format!("failed to encode public key PEM: {error}"))?;
577    let dir = trusted_signers_dir()?;
578    fs::create_dir_all(&dir)
579        .map_err(|error| format!("failed to create {}: {error}", dir.display()))?;
580    let path = dir.join(format!("{fingerprint}.pub"));
581    fs::write(&path, pem.as_bytes())
582        .map_err(|error| format!("failed to write {}: {error}", path.display()))?;
583    Ok(TrustedSignerRecord { fingerprint, path })
584}
585
586pub(crate) fn trust_list() -> Result<Vec<TrustedSignerRecord>, String> {
587    let dir = trusted_signers_dir()?;
588    if !dir.exists() {
589        return Ok(Vec::new());
590    }
591    let mut records = Vec::new();
592    let entries =
593        fs::read_dir(&dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))?;
594    for entry in entries.flatten() {
595        let path = entry.path();
596        if path.extension().and_then(|ext| ext.to_str()) != Some("pub") {
597            continue;
598        }
599        let raw = fs::read_to_string(&path)
600            .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
601        let verifying_key = VerifyingKey::from_public_key_pem(&raw)
602            .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
603        records.push(TrustedSignerRecord {
604            fingerprint: fingerprint_for_key(&verifying_key),
605            path,
606        });
607    }
608    records.sort_by(|left, right| left.fingerprint.cmp(&right.fingerprint));
609    Ok(records)
610}
611
612pub(crate) fn configured_registry_url(anchor: Option<&Path>) -> Option<String> {
613    if let Ok(raw) = std::env::var(SIGNER_REGISTRY_URL_ENV) {
614        let trimmed = raw.trim();
615        if !trimmed.is_empty() {
616            return Some(trimmed.to_string());
617        }
618    }
619    load_skills_config(anchor).and_then(|resolved| resolved.config.signer_registry_url)
620}
621
622pub(crate) fn load_trust_policy(path: &Path) -> Result<TrustPolicy, String> {
623    let raw = fs::read_to_string(path)
624        .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
625    serde_json::from_str(&raw)
626        .map_err(|error| format!("failed to parse trust policy {}: {error}", path.display()))
627}
628
629pub(crate) enum TrustedSignerStatus {
630    Trusted,
631    MissingSigner,
632    UntrustedSigner,
633}
634
635pub(crate) fn check_trusted_signer(
636    fingerprint: &str,
637    policy: Option<&TrustPolicy>,
638) -> Result<TrustedSignerStatus, String> {
639    let registry_url = policy.and_then(|policy| policy.signer_registry_url.as_deref());
640    let Some(_) = resolve_verifying_key(fingerprint, registry_url)? else {
641        return Ok(TrustedSignerStatus::MissingSigner);
642    };
643    if policy.is_some_and(|policy| {
644        !policy.trusted_signers.is_empty()
645            && !policy.trusted_signers.iter().any(|id| id == fingerprint)
646    }) {
647        return Ok(TrustedSignerStatus::UntrustedSigner);
648    }
649    Ok(TrustedSignerStatus::Trusted)
650}
651
652pub(crate) fn signature_path_for(skill_path: &Path) -> PathBuf {
653    append_suffix(skill_path, ".sig")
654}
655
656pub(crate) fn load_ed25519_signing_key(private_key_path: &Path) -> Result<SigningKey, String> {
657    let private_pem = fs::read_to_string(private_key_path)
658        .map_err(|error| format!("failed to read {}: {error}", private_key_path.display()))?;
659    SigningKey::from_pkcs8_pem(&private_pem)
660        .map_err(|error| format!("failed to parse {}: {error}", private_key_path.display()))
661}
662
663fn read_signature_envelope(signature_path: &Path) -> Result<SkillSignatureEnvelope, String> {
664    let signature_raw = fs::read_to_string(signature_path)
665        .map_err(|error| format!("failed to read {}: {error}", signature_path.display()))?;
666    serde_json::from_str(&signature_raw).map_err(|error| {
667        format!(
668            "{} is not valid {} JSON: {error}",
669            signature_path.display(),
670            SIG_SCHEMA
671        )
672    })
673}
674
675pub(crate) fn trusted_signers_dir() -> Result<PathBuf, String> {
676    user_home_dir()
677        .map(|home| home.join(".harn").join("trusted-signers"))
678        .ok_or_else(|| "could not determine the current user's home directory".to_string())
679}
680
681fn resolve_verifying_key(
682    fingerprint: &str,
683    registry_url: Option<&str>,
684) -> Result<Option<VerifyingKey>, String> {
685    let local_path = trusted_signers_dir()?.join(format!("{fingerprint}.pub"));
686    if local_path.is_file() {
687        let pem = fs::read_to_string(&local_path)
688            .map_err(|error| format!("failed to read {}: {error}", local_path.display()))?;
689        let key = VerifyingKey::from_public_key_pem(&pem)
690            .map_err(|error| format!("failed to parse {}: {error}", local_path.display()))?;
691        return Ok(Some(key));
692    }
693
694    let Some(registry_url) = registry_url else {
695        return Ok(None);
696    };
697    let pem = match fetch_registry_public_key(registry_url, fingerprint)? {
698        Some(pem) => pem,
699        None => return Ok(None),
700    };
701    let key = VerifyingKey::from_public_key_pem(&pem)
702        .map_err(|error| format!("failed to parse signer from registry: {error}"))?;
703    Ok(Some(key))
704}
705
706fn fetch_registry_public_key(
707    registry_url: &str,
708    fingerprint: &str,
709) -> Result<Option<String>, String> {
710    let filename = format!("{fingerprint}.pub");
711    if let Some(path) = file_url_or_path(registry_url)? {
712        let resolved = path.join(filename);
713        return match fs::read_to_string(&resolved) {
714            Ok(raw) => Ok(Some(raw)),
715            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
716            Err(error) => Err(format!("failed to read {}: {error}", resolved.display())),
717        };
718    }
719
720    let base = Url::parse(registry_url)
721        .map_err(|error| format!("invalid signer registry URL {registry_url:?}: {error}"))?;
722    let url = base
723        .join(&filename)
724        .map_err(|error| format!("failed to resolve signer URL from {registry_url:?}: {error}"))?;
725    let display_url = net::diagnostic_text(url.as_str());
726    let client = net::blocking_http_client("cli.skill.provenance", Duration::from_secs(20))?;
727    let response = client.get(url).send().map_err(|error| {
728        format!(
729            "failed to fetch {display_url}: {}",
730            net::reqwest_error(&error)
731        )
732    })?;
733    if response.status() == reqwest::StatusCode::NOT_FOUND {
734        return Ok(None);
735    }
736    let response = response.error_for_status().map_err(|error| {
737        format!(
738            "failed to fetch {display_url}: {}",
739            net::reqwest_error(&error)
740        )
741    })?;
742    response.text().map(Some).map_err(|error| {
743        format!(
744            "failed to read {display_url}: {}",
745            net::reqwest_error(&error)
746        )
747    })
748}
749
750fn verifying_key_from_source(from: &str) -> Result<VerifyingKey, String> {
751    let raw = if let Some(path) = file_url_or_path(from)? {
752        fs::read_to_string(&path)
753            .map_err(|error| format!("failed to read {}: {error}", path.display()))?
754    } else {
755        let url = Url::parse(from).map_err(|error| format!("invalid URL {from:?}: {error}"))?;
756        let display_url = net::diagnostic_text(url.as_str());
757        let client = net::blocking_http_client("cli.skill.provenance", Duration::from_secs(20))?;
758        let response = client.get(url).send().map_err(|error| {
759            format!(
760                "failed to fetch {display_url}: {}",
761                net::reqwest_error(&error)
762            )
763        })?;
764        let response = response.error_for_status().map_err(|error| {
765            format!(
766                "failed to fetch {display_url}: {}",
767                net::reqwest_error(&error)
768            )
769        })?;
770        response.text().map_err(|error| {
771            format!(
772                "failed to read {display_url}: {}",
773                net::reqwest_error(&error)
774            )
775        })?
776    };
777    VerifyingKey::from_public_key_pem(&raw)
778        .map_err(|error| format!("failed to parse Ed25519 public key: {error}"))
779}
780
781fn file_url_or_path(raw: &str) -> Result<Option<PathBuf>, String> {
782    if raw.starts_with("http://") || raw.starts_with("https://") {
783        return Ok(None);
784    }
785    if raw.starts_with("file://") {
786        let url = Url::parse(raw).map_err(|error| format!("invalid file URL {raw:?}: {error}"))?;
787        return url
788            .to_file_path()
789            .map(Some)
790            .map_err(|_| format!("could not convert {raw:?} into a filesystem path"));
791    }
792    Ok(Some(PathBuf::from(raw)))
793}
794
795fn append_suffix(path: &Path, suffix: &str) -> PathBuf {
796    let mut raw: OsString = path.as_os_str().to_os_string();
797    raw.push(suffix);
798    PathBuf::from(raw)
799}
800
801pub(crate) fn fingerprint_for_key(key: &VerifyingKey) -> String {
802    hex::encode(Sha256::digest(key.as_bytes()))
803}
804
805fn user_home_dir() -> Option<PathBuf> {
806    harn_vm::user_dirs::home_dir()
807}
808
809#[cfg(test)]
810mod tests {
811    use super::*;
812    use std::fs;
813
814    use crate::env_guard::ScopedEnvVar;
815    use crate::tests::common::{cwd_lock::lock_cwd, harn_state_lock::lock_harn_state};
816
817    fn write_skill(path: &Path, body: &str) {
818        fs::create_dir_all(path.parent().unwrap()).unwrap();
819        fs::write(path, body).unwrap();
820    }
821
822    fn set_home(path: &Path) -> ScopedEnvVar {
823        ScopedEnvVar::set("HOME", path.to_str().unwrap())
824    }
825
826    #[test]
827    fn keygen_sign_and_verify_roundtrip() {
828        let _cwd = lock_cwd();
829        let _env = lock_harn_state();
830        let tmp = tempfile::tempdir().unwrap();
831        let _home = set_home(tmp.path());
832
833        let skill = tmp.path().join("skill").join("SKILL.md");
834        write_skill(&skill, "---\nname: deploy\n---\nship it\n");
835        let keys = generate_keypair(tmp.path().join("signer.pem")).unwrap();
836        let signed = sign_skill(&skill, &keys.private_key_path).unwrap();
837        let signer = trust_add(keys.public_key_path.to_str().unwrap()).unwrap();
838        let report = verify_skill(&skill, &VerifyOptions::default()).unwrap();
839        assert_eq!(report.status, VerificationStatus::MissingEndorsement);
840
841        let endorser_keys = generate_keypair(tmp.path().join("endorser.pem")).unwrap();
842        trust_add(endorser_keys.public_key_path.to_str().unwrap()).unwrap();
843        endorse_skill(&skill, &endorser_keys.private_key_path).unwrap();
844        let report = verify_skill(&skill, &VerifyOptions::default()).unwrap();
845
846        assert_eq!(signed.signer_fingerprint, keys.fingerprint);
847        assert_eq!(signer.fingerprint, keys.fingerprint);
848        assert!(report.is_verified());
849        assert_eq!(
850            report.signer_fingerprint.as_deref(),
851            Some(keys.fingerprint.as_str())
852        );
853    }
854
855    #[test]
856    fn verify_rejects_tampered_skill_payload() {
857        let _cwd = lock_cwd();
858        let _env = lock_harn_state();
859        let tmp = tempfile::tempdir().unwrap();
860        let _home = set_home(tmp.path());
861
862        let skill = tmp.path().join("skill").join("SKILL.md");
863        write_skill(&skill, "---\nname: deploy\n---\nship it\n");
864        let keys = generate_keypair(tmp.path().join("signer.pem")).unwrap();
865        sign_skill(&skill, &keys.private_key_path).unwrap();
866        trust_add(keys.public_key_path.to_str().unwrap()).unwrap();
867        let endorser_keys = generate_keypair(tmp.path().join("endorser.pem")).unwrap();
868        trust_add(endorser_keys.public_key_path.to_str().unwrap()).unwrap();
869        endorse_skill(&skill, &endorser_keys.private_key_path).unwrap();
870        fs::write(&skill, "---\nname: deploy\n---\nship it now\n").unwrap();
871
872        let report = verify_skill(&skill, &VerifyOptions::default()).unwrap();
873        assert_eq!(report.status, VerificationStatus::InvalidSignature);
874    }
875
876    #[test]
877    fn verify_rejects_wrong_key_signature() {
878        let _cwd = lock_cwd();
879        let _env = lock_harn_state();
880        let tmp = tempfile::tempdir().unwrap();
881        let _home = set_home(tmp.path());
882
883        let skill = tmp.path().join("skill").join("SKILL.md");
884        write_skill(&skill, "---\nname: deploy\n---\nship it\n");
885        let signing_keys = generate_keypair(tmp.path().join("signer.pem")).unwrap();
886        let trusted_keys = generate_keypair(tmp.path().join("trusted.pem")).unwrap();
887        sign_skill(&skill, &signing_keys.private_key_path).unwrap();
888        let endorser_keys = generate_keypair(tmp.path().join("endorser.pem")).unwrap();
889        endorse_skill(&skill, &endorser_keys.private_key_path).unwrap();
890        trust_add(trusted_keys.public_key_path.to_str().unwrap()).unwrap();
891
892        let sig_path = signature_path_for(&skill);
893        let mut envelope: SkillSignatureEnvelope =
894            serde_json::from_str(&fs::read_to_string(&sig_path).unwrap()).unwrap();
895        envelope.signer_fingerprint = trusted_keys.fingerprint;
896        fs::write(&sig_path, serde_json::to_string_pretty(&envelope).unwrap()).unwrap();
897
898        let report = verify_skill(&skill, &VerifyOptions::default()).unwrap();
899        assert_eq!(report.status, VerificationStatus::InvalidSignature);
900    }
901
902    #[test]
903    fn verify_reports_missing_signer() {
904        let _cwd = lock_cwd();
905        let _env = lock_harn_state();
906        let tmp = tempfile::tempdir().unwrap();
907        let _home = set_home(tmp.path());
908
909        let skill = tmp.path().join("skill").join("SKILL.md");
910        write_skill(&skill, "---\nname: deploy\n---\nship it\n");
911        let keys = generate_keypair(tmp.path().join("signer.pem")).unwrap();
912        sign_skill(&skill, &keys.private_key_path).unwrap();
913        let endorser_keys = generate_keypair(tmp.path().join("endorser.pem")).unwrap();
914        endorse_skill(&skill, &endorser_keys.private_key_path).unwrap();
915
916        let report = verify_skill(&skill, &VerifyOptions::default()).unwrap();
917        assert_eq!(report.status, VerificationStatus::MissingSigner);
918        assert!(report.signed);
919        assert!(!report.trusted);
920    }
921
922    #[test]
923    fn verify_honors_allowed_signers() {
924        let _cwd = lock_cwd();
925        let _env = lock_harn_state();
926        let tmp = tempfile::tempdir().unwrap();
927        let _home = set_home(tmp.path());
928
929        let skill = tmp.path().join("skill").join("SKILL.md");
930        write_skill(&skill, "---\nname: deploy\n---\nship it\n");
931        let keys = generate_keypair(tmp.path().join("signer.pem")).unwrap();
932        sign_skill(&skill, &keys.private_key_path).unwrap();
933        trust_add(keys.public_key_path.to_str().unwrap()).unwrap();
934        let endorser_keys = generate_keypair(tmp.path().join("endorser.pem")).unwrap();
935        trust_add(endorser_keys.public_key_path.to_str().unwrap()).unwrap();
936        endorse_skill(&skill, &endorser_keys.private_key_path).unwrap();
937
938        let report = verify_skill(
939            &skill,
940            &VerifyOptions {
941                allowed_signers: vec!["not-the-signer".to_string()],
942                ..Default::default()
943            },
944        )
945        .unwrap();
946        assert_eq!(report.status, VerificationStatus::UntrustedSigner);
947
948        let report = verify_skill(
949            &skill,
950            &VerifyOptions {
951                allowed_signers: vec![keys.fingerprint],
952                allowed_endorsers: vec!["not-the-endorser".to_string()],
953                ..Default::default()
954            },
955        )
956        .unwrap();
957        assert_eq!(report.status, VerificationStatus::UntrustedSigner);
958    }
959
960    #[test]
961    fn verify_rejects_missing_endorsement() {
962        let _cwd = lock_cwd();
963        let _env = lock_harn_state();
964        let tmp = tempfile::tempdir().unwrap();
965        let _home = set_home(tmp.path());
966
967        let skill = tmp.path().join("skill").join("SKILL.md");
968        write_skill(&skill, "---\nname: deploy\n---\nship it\n");
969        let keys = generate_keypair(tmp.path().join("signer.pem")).unwrap();
970        sign_skill(&skill, &keys.private_key_path).unwrap();
971        trust_add(keys.public_key_path.to_str().unwrap()).unwrap();
972
973        let report = verify_skill(&skill, &VerifyOptions::default()).unwrap();
974        assert_eq!(report.status, VerificationStatus::MissingEndorsement);
975        assert!(report.signed);
976        assert!(!report.trusted);
977    }
978}