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