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