Skip to main content

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}