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}