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