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