Skip to main content

harn_cli/
skill_provenance.rs

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