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