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}