harness_core/pack_sig.rs
1// SPDX-License-Identifier: Apache-2.0 OR MIT
2//! Signature verification for remotely-fetched policy packs.
3//!
4//! EMPIRICAL DISCOVERY (2026-05-25, against
5//! https://aiegis.ie/v1/harness/policy-packs/):
6//!
7//! - `<pack>/<ver>.tar.gz` — gzip'd tar containing `manifest.json` plus the
8//! `<name>.rego` file(s) named in the manifest's `rego_files` array.
9//! - `<pack>/<ver>.sig` — exactly 64 raw bytes, the Ed25519 signature
10//! over `sha256(tarball_bytes)` (NOT over the tarball directly, NOT over
11//! a canonicalised manifest). Confirmed against
12//! `/opt/aegis/aegis-registry/src/policy_packs.py::_sign_tarball_sha`
13//! which calls `priv.sign(hashlib.sha256(tarball).digest())`.
14//! - The issuer key is Ed25519, raw 32-byte public key
15//! `e2eec1ad61e7f02051124dca8c53208b5f5524be47f5e813da86caea98433737`,
16//! loaded by the publisher at runtime from
17//! `/opt/aegis/config/aiegis_v1_issuer.key` (env
18//! `AIEGIS_V1_ISSUER_KEY_PATH`).
19//!
20//! GAP (needs-iteration): the publisher's docstring states the key SHOULD be
21//! discoverable at `/.well-known/did.json`, but the live `did.json` resolves
22//! to a *different* (P-256) key handled by a separate pages handler. There
23//! is no `aiegis.ie/v1/harness/policy-packs/.well-known/issuer-key.json`.
24//! Until Nel publishes a stable discovery endpoint for the harness issuer
25//! key, the daemon pins the value via `--issuer-pubkey-hex` with a built-in
26//! default that matches the live key. The verify path itself is real
27//! Ed25519 — no stub, no skip.
28
29use ed25519_dalek::{Signature, Verifier, VerifyingKey};
30use sha2::{Digest, Sha256};
31
32/// Default issuer pubkey discovered empirically on 2026-05-25.
33///
34/// Tracked separately so callers can override via CLI for staging /
35/// federation deploys without recompiling.
36pub const DEFAULT_PACK_ISSUER_PUBKEY_HEX: &str =
37 "e2eec1ad61e7f02051124dca8c53208b5f5524be47f5e813da86caea98433737";
38
39#[derive(Debug)]
40pub enum SigError {
41 PubkeyHexInvalid(String),
42 PubkeyWrongLength(usize),
43 SigWrongLength(usize),
44 SignatureRejected(String),
45}
46
47impl std::fmt::Display for SigError {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 match self {
50 SigError::PubkeyHexInvalid(s) => write!(f, "pack_sig_pubkey_hex_invalid:{s}"),
51 SigError::PubkeyWrongLength(n) => {
52 write!(f, "pack_sig_pubkey_wrong_length: got {n} want 32")
53 }
54 SigError::SigWrongLength(n) => {
55 write!(f, "pack_sig_signature_wrong_length: got {n} want 64")
56 }
57 SigError::SignatureRejected(s) => write!(f, "pack_sig_rejected:{s}"),
58 }
59 }
60}
61
62impl std::error::Error for SigError {}
63
64/// Verify a policy-pack tarball signature using the empirically-discovered
65/// scheme: Ed25519 over sha256(tarball_bytes).
66///
67/// `pubkey_hex` is the issuer's raw 32-byte Ed25519 public key, hex-encoded
68/// (64 chars). `tarball_bytes` is the verbatim `.tar.gz` contents. `sig_bytes`
69/// is the 64-byte raw signature read from `<ver>.sig`.
70pub fn verify_pack_tarball(
71 pubkey_hex: &str,
72 tarball_bytes: &[u8],
73 sig_bytes: &[u8],
74) -> Result<(), SigError> {
75 let pk_bytes = hex::decode(pubkey_hex.trim())
76 .map_err(|e| SigError::PubkeyHexInvalid(format!("{e}")))?;
77 if pk_bytes.len() != 32 {
78 return Err(SigError::PubkeyWrongLength(pk_bytes.len()));
79 }
80 let mut pk_arr = [0u8; 32];
81 pk_arr.copy_from_slice(&pk_bytes);
82 let vk = VerifyingKey::from_bytes(&pk_arr)
83 .map_err(|e| SigError::PubkeyHexInvalid(format!("{e}")))?;
84
85 if sig_bytes.len() != 64 {
86 return Err(SigError::SigWrongLength(sig_bytes.len()));
87 }
88 let mut sig_arr = [0u8; 64];
89 sig_arr.copy_from_slice(sig_bytes);
90 let sig = Signature::from_bytes(&sig_arr);
91
92 let mut hasher = Sha256::new();
93 hasher.update(tarball_bytes);
94 let digest = hasher.finalize();
95
96 vk.verify(&digest, &sig)
97 .map_err(|e| SigError::SignatureRejected(format!("{e}")))
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 /// Negative test: a bad signature rejects.
105 #[test]
106 fn rejects_bad_signature() {
107 let pk_hex = DEFAULT_PACK_ISSUER_PUBKEY_HEX;
108 let tar = b"some bytes";
109 let bad_sig = [0u8; 64];
110 let err = verify_pack_tarball(pk_hex, tar, &bad_sig);
111 assert!(err.is_err());
112 }
113
114 /// Length sanity: a too-short sig is rejected with the right error
115 /// before crypto is touched.
116 #[test]
117 fn rejects_short_signature() {
118 let err = verify_pack_tarball(DEFAULT_PACK_ISSUER_PUBKEY_HEX, b"x", &[0u8; 32]);
119 match err {
120 Err(SigError::SigWrongLength(32)) => {}
121 other => panic!("unexpected: {other:?}"),
122 }
123 }
124
125 #[test]
126 fn pubkey_format_validated() {
127 let err = verify_pack_tarball("not-hex", b"x", &[0u8; 64]);
128 match err {
129 Err(SigError::PubkeyHexInvalid(_)) => {}
130 other => panic!("unexpected: {other:?}"),
131 }
132 }
133}