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 private_pem = fs::read_to_string(private_key_path)
212        .map_err(|error| format!("failed to read {}: {error}", private_key_path.display()))?;
213    let signing_key = SigningKey::from_pkcs8_pem(&private_pem)
214        .map_err(|error| format!("failed to parse {}: {error}", private_key_path.display()))?;
215    let signature = signing_key.sign(&skill_bytes);
216    let signer_fingerprint = fingerprint_for_key(&signing_key.verifying_key());
217    let skill_sha256 = sha256_hex(&skill_bytes);
218    let signed_at = time::OffsetDateTime::now_utc()
219        .format(&Rfc3339)
220        .map_err(|error| format!("failed to format signed_at timestamp: {error}"))?;
221    let envelope = SkillSignatureEnvelope {
222        schema: SIG_SCHEMA.to_string(),
223        signed_at,
224        signer_fingerprint: signer_fingerprint.clone(),
225        ed25519_sig_base64: base64::engine::general_purpose::STANDARD.encode(signature.to_bytes()),
226        skill_sha256: skill_sha256.clone(),
227        endorsements: Vec::new(),
228    };
229    let signature_path = signature_path_for(skill_path);
230    let serialized = serde_json::to_string_pretty(&envelope)
231        .map_err(|error| format!("failed to serialize signature: {error}"))?;
232    fs::write(&signature_path, serialized.as_bytes()).map_err(|error| {
233        format!(
234            "failed to write signature {}: {error}",
235            signature_path.display()
236        )
237    })?;
238
239    Ok(SignedSkill {
240        signature_path,
241        signer_fingerprint,
242        skill_sha256,
243    })
244}
245
246pub(crate) fn endorse_skill(
247    skill_path: impl AsRef<Path>,
248    private_key_path: impl AsRef<Path>,
249) -> Result<EndorsedSkill, String> {
250    let skill_path = skill_path.as_ref();
251    let private_key_path = private_key_path.as_ref();
252    let skill_bytes = fs::read(skill_path)
253        .map_err(|error| format!("failed to read {}: {error}", skill_path.display()))?;
254    let skill_sha256 = sha256_hex(&skill_bytes);
255    let signature_path = signature_path_for(skill_path);
256    let mut envelope = read_signature_envelope(&signature_path)?;
257    if envelope.schema != SIG_SCHEMA {
258        return Err(format!(
259            "{} declares unsupported schema {}",
260            signature_path.display(),
261            envelope.schema
262        ));
263    }
264    if envelope.skill_sha256 != skill_sha256 {
265        return Err(format!(
266            "{} does not match the current contents of {}",
267            signature_path.display(),
268            skill_path.display()
269        ));
270    }
271
272    let private_pem = fs::read_to_string(private_key_path)
273        .map_err(|error| format!("failed to read {}: {error}", private_key_path.display()))?;
274    let signing_key = SigningKey::from_pkcs8_pem(&private_pem)
275        .map_err(|error| format!("failed to parse {}: {error}", private_key_path.display()))?;
276    let endorser_fingerprint = fingerprint_for_key(&signing_key.verifying_key());
277    if endorser_fingerprint == envelope.signer_fingerprint {
278        return Err(
279            "skill endorsements must be signed by a different key than the author signature"
280                .to_string(),
281        );
282    }
283    let signed_at = time::OffsetDateTime::now_utc()
284        .format(&Rfc3339)
285        .map_err(|error| format!("failed to format signed_at timestamp: {error}"))?;
286    let endorsement = SkillSignatureEndorsement {
287        signed_at,
288        endorser_fingerprint: endorser_fingerprint.clone(),
289        ed25519_sig_base64: base64::engine::general_purpose::STANDARD
290            .encode(signing_key.sign(&skill_bytes).to_bytes()),
291    };
292    envelope
293        .endorsements
294        .retain(|existing| existing.endorser_fingerprint != endorser_fingerprint);
295    envelope.endorsements.push(endorsement);
296    envelope
297        .endorsements
298        .sort_by(|left, right| left.endorser_fingerprint.cmp(&right.endorser_fingerprint));
299    let serialized = serde_json::to_string_pretty(&envelope)
300        .map_err(|error| format!("failed to serialize signature: {error}"))?;
301    fs::write(&signature_path, serialized.as_bytes()).map_err(|error| {
302        format!(
303            "failed to write signature {}: {error}",
304            signature_path.display()
305        )
306    })?;
307
308    Ok(EndorsedSkill {
309        signature_path,
310        endorser_fingerprint,
311        skill_sha256,
312    })
313}
314
315pub(crate) fn verify_skill(
316    skill_path: impl AsRef<Path>,
317    options: &VerifyOptions,
318) -> Result<VerificationReport, String> {
319    let skill_path = skill_path.as_ref();
320    let skill_bytes = fs::read(skill_path)
321        .map_err(|error| format!("failed to read {}: {error}", skill_path.display()))?;
322    let skill_sha256 = sha256_hex(&skill_bytes);
323    let signature_path = signature_path_for(skill_path);
324    let allowed_signers: BTreeSet<String> = options.allowed_signers.iter().cloned().collect();
325    let base_report = VerificationReport {
326        skill_path: skill_path.to_path_buf(),
327        signature_path: signature_path.clone(),
328        skill_sha256: skill_sha256.clone(),
329        signer_fingerprint: None,
330        signed_at: None,
331        endorsements: Vec::new(),
332        signed: false,
333        trusted: false,
334        status: VerificationStatus::MissingSignature,
335        error: None,
336    };
337
338    let signature_raw = match fs::read_to_string(&signature_path) {
339        Ok(raw) => raw,
340        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(base_report),
341        Err(error) => {
342            return Err(format!(
343                "failed to read signature {}: {error}",
344                signature_path.display()
345            ))
346        }
347    };
348    let envelope: SkillSignatureEnvelope = match serde_json::from_str(&signature_raw) {
349        Ok(envelope) => envelope,
350        Err(error) => {
351            return Ok(VerificationReport {
352                error: Some(format!(
353                    "{} is not valid {} JSON: {error}",
354                    signature_path.display(),
355                    SIG_SCHEMA
356                )),
357                status: VerificationStatus::InvalidSignature,
358                ..base_report
359            })
360        }
361    };
362    if envelope.schema != SIG_SCHEMA {
363        return Ok(VerificationReport {
364            signer_fingerprint: Some(envelope.signer_fingerprint),
365            status: VerificationStatus::InvalidSignature,
366            error: Some(format!(
367                "{} declares unsupported schema {}",
368                signature_path.display(),
369                envelope.schema
370            )),
371            ..base_report
372        });
373    }
374    if envelope.skill_sha256 != skill_sha256 {
375        return Ok(VerificationReport {
376            signer_fingerprint: Some(envelope.signer_fingerprint),
377            status: VerificationStatus::InvalidSignature,
378            error: Some(format!(
379                "{} does not match the current contents of {}",
380                signature_path.display(),
381                skill_path.display()
382            )),
383            ..base_report
384        });
385    }
386
387    let signer_fingerprint = envelope.signer_fingerprint.clone();
388    let base_report = VerificationReport {
389        signer_fingerprint: Some(signer_fingerprint.clone()),
390        signed_at: Some(envelope.signed_at.clone()),
391        signed: true,
392        ..base_report
393    };
394
395    let verifying_key =
396        match resolve_verifying_key(&signer_fingerprint, options.registry_url.as_deref())? {
397            Some(key) => key,
398            None => {
399                return Ok(VerificationReport {
400                    status: VerificationStatus::MissingSigner,
401                    error: Some(format!(
402                        "{} was signed by {}, but {} is not present in {}",
403                        skill_path.display(),
404                        signer_fingerprint,
405                        signer_fingerprint,
406                        trusted_signers_dir()?.display()
407                    )),
408                    ..base_report
409                })
410            }
411        };
412    let signature_bytes = match base64::engine::general_purpose::STANDARD
413        .decode(envelope.ed25519_sig_base64.as_bytes())
414    {
415        Ok(bytes) => bytes,
416        Err(error) => {
417            return Ok(VerificationReport {
418                status: VerificationStatus::InvalidSignature,
419                error: Some(format!("signature is not valid base64: {error}")),
420                ..base_report
421            })
422        }
423    };
424    let signature = match Signature::from_slice(&signature_bytes) {
425        Ok(signature) => signature,
426        Err(error) => {
427            return Ok(VerificationReport {
428                status: VerificationStatus::InvalidSignature,
429                error: Some(format!("signature is not valid Ed25519 bytes: {error}")),
430                ..base_report
431            })
432        }
433    };
434    if verifying_key.verify(&skill_bytes, &signature).is_err() {
435        return Ok(VerificationReport {
436            status: VerificationStatus::InvalidSignature,
437            error: Some(format!(
438                "{} failed Ed25519 verification for {}",
439                signature_path.display(),
440                skill_path.display()
441            )),
442            ..base_report
443        });
444    }
445    if !allowed_signers.is_empty() && !allowed_signers.contains(&signer_fingerprint) {
446        return Ok(VerificationReport {
447            status: VerificationStatus::UntrustedSigner,
448            error: Some(format!(
449                "{} was signed by {}, which is not in the skill's trusted_signers allowlist",
450                skill_path.display(),
451                signer_fingerprint
452            )),
453            ..base_report
454        });
455    }
456
457    let endorsement_reports = verify_endorsements(&skill_bytes, &envelope.endorsements, options)?;
458    if endorsement_reports.is_empty() {
459        return Ok(VerificationReport {
460            endorsements: endorsement_reports,
461            status: VerificationStatus::MissingEndorsement,
462            error: Some(format!(
463                "{} has no endorsement signatures; add at least one with `harn skill endorse`",
464                skill_path.display()
465            )),
466            ..base_report
467        });
468    }
469    if let Some(failed) = endorsement_reports
470        .iter()
471        .find(|endorsement| endorsement.status != VerificationStatus::Verified)
472    {
473        return Ok(VerificationReport {
474            endorsements: endorsement_reports.clone(),
475            status: failed.status,
476            error: failed.error.clone(),
477            ..base_report
478        });
479    }
480
481    Ok(VerificationReport {
482        endorsements: endorsement_reports,
483        trusted: true,
484        status: VerificationStatus::Verified,
485        ..base_report
486    })
487}
488
489fn verify_endorsements(
490    skill_bytes: &[u8],
491    endorsements: &[SkillSignatureEndorsement],
492    options: &VerifyOptions,
493) -> Result<Vec<EndorsementReport>, String> {
494    let allowed_endorsers: BTreeSet<String> = options.allowed_endorsers.iter().cloned().collect();
495    endorsements
496        .iter()
497        .map(|endorsement| {
498            let fingerprint = endorsement.endorser_fingerprint.clone();
499            let base_report = EndorsementReport {
500                endorser_fingerprint: fingerprint.clone(),
501                signed_at: endorsement.signed_at.clone(),
502                trusted: false,
503                status: VerificationStatus::InvalidSignature,
504                error: None,
505            };
506            let Some(verifying_key) =
507                resolve_verifying_key(&fingerprint, options.registry_url.as_deref())?
508            else {
509                return Ok(EndorsementReport {
510                    status: VerificationStatus::MissingSigner,
511                    error: Some(format!(
512                        "endorsement signer {fingerprint} is not installed locally and no registry resolved it"
513                    )),
514                    ..base_report
515                });
516            };
517            if !allowed_endorsers.is_empty() && !allowed_endorsers.contains(&fingerprint) {
518                return Ok(EndorsementReport {
519                    status: VerificationStatus::UntrustedSigner,
520                    error: Some(format!(
521                        "endorsement signer {fingerprint} is not in the skill's trusted_endorsers allowlist"
522                    )),
523                    ..base_report
524                });
525            }
526            let signature_bytes = match base64::engine::general_purpose::STANDARD
527                .decode(endorsement.ed25519_sig_base64.as_bytes())
528            {
529                Ok(bytes) => bytes,
530                Err(error) => {
531                    return Ok(EndorsementReport {
532                        error: Some(format!(
533                            "endorsement signature for {fingerprint} is not valid base64: {error}"
534                        )),
535                        ..base_report
536                    })
537                }
538            };
539            let signature = match Signature::from_slice(&signature_bytes) {
540                Ok(signature) => signature,
541                Err(error) => {
542                    return Ok(EndorsementReport {
543                        error: Some(format!(
544                            "endorsement signature for {fingerprint} is not valid Ed25519 bytes: {error}"
545                        )),
546                        ..base_report
547                    })
548                }
549            };
550            if verifying_key.verify(skill_bytes, &signature).is_err() {
551                return Ok(EndorsementReport {
552                    error: Some(format!(
553                        "endorsement signature for {fingerprint} failed Ed25519 verification"
554                    )),
555                    ..base_report
556                });
557            }
558            Ok(EndorsementReport {
559                trusted: true,
560                status: VerificationStatus::Verified,
561                ..base_report
562            })
563        })
564        .collect()
565}
566
567pub(crate) fn trust_add(from: &str) -> Result<TrustedSignerRecord, String> {
568    let verifying_key = verifying_key_from_source(from)?;
569    let fingerprint = fingerprint_for_key(&verifying_key);
570    let pem = verifying_key
571        .to_public_key_pem(LineEnding::LF)
572        .map_err(|error| format!("failed to encode public key PEM: {error}"))?;
573    let dir = trusted_signers_dir()?;
574    fs::create_dir_all(&dir)
575        .map_err(|error| format!("failed to create {}: {error}", dir.display()))?;
576    let path = dir.join(format!("{fingerprint}.pub"));
577    fs::write(&path, pem.as_bytes())
578        .map_err(|error| format!("failed to write {}: {error}", path.display()))?;
579    Ok(TrustedSignerRecord { fingerprint, path })
580}
581
582pub(crate) fn trust_list() -> Result<Vec<TrustedSignerRecord>, String> {
583    let dir = trusted_signers_dir()?;
584    if !dir.exists() {
585        return Ok(Vec::new());
586    }
587    let mut records = Vec::new();
588    let entries =
589        fs::read_dir(&dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))?;
590    for entry in entries.flatten() {
591        let path = entry.path();
592        if path.extension().and_then(|ext| ext.to_str()) != Some("pub") {
593            continue;
594        }
595        let raw = fs::read_to_string(&path)
596            .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
597        let verifying_key = VerifyingKey::from_public_key_pem(&raw)
598            .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
599        records.push(TrustedSignerRecord {
600            fingerprint: fingerprint_for_key(&verifying_key),
601            path,
602        });
603    }
604    records.sort_by(|left, right| left.fingerprint.cmp(&right.fingerprint));
605    Ok(records)
606}
607
608pub(crate) fn configured_registry_url(anchor: Option<&Path>) -> Option<String> {
609    if let Ok(raw) = std::env::var(SIGNER_REGISTRY_URL_ENV) {
610        let trimmed = raw.trim();
611        if !trimmed.is_empty() {
612            return Some(trimmed.to_string());
613        }
614    }
615    load_skills_config(anchor).and_then(|resolved| resolved.config.signer_registry_url)
616}
617
618pub(crate) fn signature_path_for(skill_path: &Path) -> PathBuf {
619    append_suffix(skill_path, ".sig")
620}
621
622fn read_signature_envelope(signature_path: &Path) -> Result<SkillSignatureEnvelope, String> {
623    let signature_raw = fs::read_to_string(signature_path)
624        .map_err(|error| format!("failed to read {}: {error}", signature_path.display()))?;
625    serde_json::from_str(&signature_raw).map_err(|error| {
626        format!(
627            "{} is not valid {} JSON: {error}",
628            signature_path.display(),
629            SIG_SCHEMA
630        )
631    })
632}
633
634pub(crate) fn trusted_signers_dir() -> Result<PathBuf, String> {
635    user_home_dir()
636        .map(|home| home.join(".harn").join("trusted-signers"))
637        .ok_or_else(|| "could not determine the current user's home directory".to_string())
638}
639
640fn resolve_verifying_key(
641    fingerprint: &str,
642    registry_url: Option<&str>,
643) -> Result<Option<VerifyingKey>, String> {
644    let local_path = trusted_signers_dir()?.join(format!("{fingerprint}.pub"));
645    if local_path.is_file() {
646        let pem = fs::read_to_string(&local_path)
647            .map_err(|error| format!("failed to read {}: {error}", local_path.display()))?;
648        let key = VerifyingKey::from_public_key_pem(&pem)
649            .map_err(|error| format!("failed to parse {}: {error}", local_path.display()))?;
650        return Ok(Some(key));
651    }
652
653    let Some(registry_url) = registry_url else {
654        return Ok(None);
655    };
656    let pem = match fetch_registry_public_key(registry_url, fingerprint)? {
657        Some(pem) => pem,
658        None => return Ok(None),
659    };
660    let key = VerifyingKey::from_public_key_pem(&pem)
661        .map_err(|error| format!("failed to parse signer from registry: {error}"))?;
662    Ok(Some(key))
663}
664
665fn fetch_registry_public_key(
666    registry_url: &str,
667    fingerprint: &str,
668) -> Result<Option<String>, String> {
669    let filename = format!("{fingerprint}.pub");
670    if let Some(path) = file_url_or_path(registry_url)? {
671        let resolved = path.join(filename);
672        return match fs::read_to_string(&resolved) {
673            Ok(raw) => Ok(Some(raw)),
674            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
675            Err(error) => Err(format!("failed to read {}: {error}", resolved.display())),
676        };
677    }
678
679    let base = Url::parse(registry_url)
680        .map_err(|error| format!("invalid signer registry URL {registry_url:?}: {error}"))?;
681    let url = base
682        .join(&filename)
683        .map_err(|error| format!("failed to resolve signer URL from {registry_url:?}: {error}"))?;
684    let response = reqwest::blocking::get(url.clone())
685        .map_err(|error| format!("failed to fetch {url}: {error}"))?;
686    if response.status() == reqwest::StatusCode::NOT_FOUND {
687        return Ok(None);
688    }
689    let response = response
690        .error_for_status()
691        .map_err(|error| format!("failed to fetch {url}: {error}"))?;
692    response
693        .text()
694        .map(Some)
695        .map_err(|error| format!("failed to read {url}: {error}"))
696}
697
698fn verifying_key_from_source(from: &str) -> Result<VerifyingKey, String> {
699    let raw = if let Some(path) = file_url_or_path(from)? {
700        fs::read_to_string(&path)
701            .map_err(|error| format!("failed to read {}: {error}", path.display()))?
702    } else {
703        let url = Url::parse(from).map_err(|error| format!("invalid URL {from:?}: {error}"))?;
704        let response = reqwest::blocking::get(url.clone())
705            .map_err(|error| format!("failed to fetch {url}: {error}"))?;
706        let response = response
707            .error_for_status()
708            .map_err(|error| format!("failed to fetch {url}: {error}"))?;
709        response
710            .text()
711            .map_err(|error| format!("failed to read {url}: {error}"))?
712    };
713    VerifyingKey::from_public_key_pem(&raw)
714        .map_err(|error| format!("failed to parse Ed25519 public key: {error}"))
715}
716
717fn file_url_or_path(raw: &str) -> Result<Option<PathBuf>, String> {
718    if raw.starts_with("http://") || raw.starts_with("https://") {
719        return Ok(None);
720    }
721    if raw.starts_with("file://") {
722        let url = Url::parse(raw).map_err(|error| format!("invalid file URL {raw:?}: {error}"))?;
723        return url
724            .to_file_path()
725            .map(Some)
726            .map_err(|_| format!("could not convert {raw:?} into a filesystem path"));
727    }
728    Ok(Some(PathBuf::from(raw)))
729}
730
731fn append_suffix(path: &Path, suffix: &str) -> PathBuf {
732    let mut raw: OsString = path.as_os_str().to_os_string();
733    raw.push(suffix);
734    PathBuf::from(raw)
735}
736
737fn sha256_hex(bytes: &[u8]) -> String {
738    let digest = Sha256::digest(bytes);
739    hex_encode(&digest)
740}
741
742fn fingerprint_for_key(key: &VerifyingKey) -> String {
743    let digest = Sha256::digest(key.as_bytes());
744    hex_encode(&digest)
745}
746
747fn hex_encode(bytes: &[u8]) -> String {
748    let mut out = String::with_capacity(bytes.len() * 2);
749    for byte in bytes {
750        out.push_str(&format!("{byte:02x}"));
751    }
752    out
753}
754
755fn user_home_dir() -> Option<PathBuf> {
756    std::env::var_os("HOME")
757        .map(PathBuf::from)
758        .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764    use std::fs;
765
766    use crate::env_guard::ScopedEnvVar;
767    use crate::tests::common::{cwd_lock::lock_cwd, env_lock::lock_env};
768
769    fn write_skill(path: &Path, body: &str) {
770        fs::create_dir_all(path.parent().unwrap()).unwrap();
771        fs::write(path, body).unwrap();
772    }
773
774    fn set_home(path: &Path) -> ScopedEnvVar {
775        ScopedEnvVar::set("HOME", path.to_str().unwrap())
776    }
777
778    #[test]
779    fn keygen_sign_and_verify_roundtrip() {
780        let _cwd = lock_cwd();
781        let _env = lock_env().blocking_lock();
782        let tmp = tempfile::tempdir().unwrap();
783        let _home = set_home(tmp.path());
784
785        let skill = tmp.path().join("skill").join("SKILL.md");
786        write_skill(&skill, "---\nname: deploy\n---\nship it\n");
787        let keys = generate_keypair(tmp.path().join("signer.pem")).unwrap();
788        let signed = sign_skill(&skill, &keys.private_key_path).unwrap();
789        let signer = trust_add(keys.public_key_path.to_str().unwrap()).unwrap();
790        let report = verify_skill(&skill, &VerifyOptions::default()).unwrap();
791        assert_eq!(report.status, VerificationStatus::MissingEndorsement);
792
793        let endorser_keys = generate_keypair(tmp.path().join("endorser.pem")).unwrap();
794        trust_add(endorser_keys.public_key_path.to_str().unwrap()).unwrap();
795        endorse_skill(&skill, &endorser_keys.private_key_path).unwrap();
796        let report = verify_skill(&skill, &VerifyOptions::default()).unwrap();
797
798        assert_eq!(signed.signer_fingerprint, keys.fingerprint);
799        assert_eq!(signer.fingerprint, keys.fingerprint);
800        assert!(report.is_verified());
801        assert_eq!(
802            report.signer_fingerprint.as_deref(),
803            Some(keys.fingerprint.as_str())
804        );
805    }
806
807    #[test]
808    fn verify_rejects_tampered_skill_payload() {
809        let _cwd = lock_cwd();
810        let _env = lock_env().blocking_lock();
811        let tmp = tempfile::tempdir().unwrap();
812        let _home = set_home(tmp.path());
813
814        let skill = tmp.path().join("skill").join("SKILL.md");
815        write_skill(&skill, "---\nname: deploy\n---\nship it\n");
816        let keys = generate_keypair(tmp.path().join("signer.pem")).unwrap();
817        sign_skill(&skill, &keys.private_key_path).unwrap();
818        trust_add(keys.public_key_path.to_str().unwrap()).unwrap();
819        let endorser_keys = generate_keypair(tmp.path().join("endorser.pem")).unwrap();
820        trust_add(endorser_keys.public_key_path.to_str().unwrap()).unwrap();
821        endorse_skill(&skill, &endorser_keys.private_key_path).unwrap();
822        fs::write(&skill, "---\nname: deploy\n---\nship it now\n").unwrap();
823
824        let report = verify_skill(&skill, &VerifyOptions::default()).unwrap();
825        assert_eq!(report.status, VerificationStatus::InvalidSignature);
826    }
827
828    #[test]
829    fn verify_rejects_wrong_key_signature() {
830        let _cwd = lock_cwd();
831        let _env = lock_env().blocking_lock();
832        let tmp = tempfile::tempdir().unwrap();
833        let _home = set_home(tmp.path());
834
835        let skill = tmp.path().join("skill").join("SKILL.md");
836        write_skill(&skill, "---\nname: deploy\n---\nship it\n");
837        let signing_keys = generate_keypair(tmp.path().join("signer.pem")).unwrap();
838        let trusted_keys = generate_keypair(tmp.path().join("trusted.pem")).unwrap();
839        sign_skill(&skill, &signing_keys.private_key_path).unwrap();
840        let endorser_keys = generate_keypair(tmp.path().join("endorser.pem")).unwrap();
841        endorse_skill(&skill, &endorser_keys.private_key_path).unwrap();
842        trust_add(trusted_keys.public_key_path.to_str().unwrap()).unwrap();
843
844        let sig_path = signature_path_for(&skill);
845        let mut envelope: SkillSignatureEnvelope =
846            serde_json::from_str(&fs::read_to_string(&sig_path).unwrap()).unwrap();
847        envelope.signer_fingerprint = trusted_keys.fingerprint.clone();
848        fs::write(&sig_path, serde_json::to_string_pretty(&envelope).unwrap()).unwrap();
849
850        let report = verify_skill(&skill, &VerifyOptions::default()).unwrap();
851        assert_eq!(report.status, VerificationStatus::InvalidSignature);
852    }
853
854    #[test]
855    fn verify_reports_missing_signer() {
856        let _cwd = lock_cwd();
857        let _env = lock_env().blocking_lock();
858        let tmp = tempfile::tempdir().unwrap();
859        let _home = set_home(tmp.path());
860
861        let skill = tmp.path().join("skill").join("SKILL.md");
862        write_skill(&skill, "---\nname: deploy\n---\nship it\n");
863        let keys = generate_keypair(tmp.path().join("signer.pem")).unwrap();
864        sign_skill(&skill, &keys.private_key_path).unwrap();
865        let endorser_keys = generate_keypair(tmp.path().join("endorser.pem")).unwrap();
866        endorse_skill(&skill, &endorser_keys.private_key_path).unwrap();
867
868        let report = verify_skill(&skill, &VerifyOptions::default()).unwrap();
869        assert_eq!(report.status, VerificationStatus::MissingSigner);
870        assert!(report.signed);
871        assert!(!report.trusted);
872    }
873
874    #[test]
875    fn verify_honors_allowed_signers() {
876        let _cwd = lock_cwd();
877        let _env = lock_env().blocking_lock();
878        let tmp = tempfile::tempdir().unwrap();
879        let _home = set_home(tmp.path());
880
881        let skill = tmp.path().join("skill").join("SKILL.md");
882        write_skill(&skill, "---\nname: deploy\n---\nship it\n");
883        let keys = generate_keypair(tmp.path().join("signer.pem")).unwrap();
884        sign_skill(&skill, &keys.private_key_path).unwrap();
885        trust_add(keys.public_key_path.to_str().unwrap()).unwrap();
886        let endorser_keys = generate_keypair(tmp.path().join("endorser.pem")).unwrap();
887        trust_add(endorser_keys.public_key_path.to_str().unwrap()).unwrap();
888        endorse_skill(&skill, &endorser_keys.private_key_path).unwrap();
889
890        let report = verify_skill(
891            &skill,
892            &VerifyOptions {
893                allowed_signers: vec!["not-the-signer".to_string()],
894                ..Default::default()
895            },
896        )
897        .unwrap();
898        assert_eq!(report.status, VerificationStatus::UntrustedSigner);
899
900        let report = verify_skill(
901            &skill,
902            &VerifyOptions {
903                allowed_signers: vec![keys.fingerprint.clone()],
904                allowed_endorsers: vec!["not-the-endorser".to_string()],
905                ..Default::default()
906            },
907        )
908        .unwrap();
909        assert_eq!(report.status, VerificationStatus::UntrustedSigner);
910    }
911
912    #[test]
913    fn verify_rejects_missing_endorsement() {
914        let _cwd = lock_cwd();
915        let _env = lock_env().blocking_lock();
916        let tmp = tempfile::tempdir().unwrap();
917        let _home = set_home(tmp.path());
918
919        let skill = tmp.path().join("skill").join("SKILL.md");
920        write_skill(&skill, "---\nname: deploy\n---\nship it\n");
921        let keys = generate_keypair(tmp.path().join("signer.pem")).unwrap();
922        sign_skill(&skill, &keys.private_key_path).unwrap();
923        trust_add(keys.public_key_path.to_str().unwrap()).unwrap();
924
925        let report = verify_skill(&skill, &VerifyOptions::default()).unwrap();
926        assert_eq!(report.status, VerificationStatus::MissingEndorsement);
927        assert!(report.signed);
928        assert!(!report.trusted);
929    }
930}