Skip to main content

mati_core/
policy.rs

1//! Signed policy-floor bundle verification (idea 1.3 — control plane).
2//!
3//! A *policy floor* is a set of org rules a repo inherits and may strengthen
4//! but never weaken. The core's role is narrow and INERT by itself: given a
5//! bundle and trusted public keys, verify the Ed25519 signature over the
6//! canonical payload and return the rules. It does NOT author or sign bundles
7//! — that is mati-cloud's licensed responsibility. The open-core boundary is
8//! exactly the trust anchor: the OSS build ships NO trusted signer key (see
9//! [`default_trusted_keys`]), so no bundle verifies and the floor is dormant
10//! until an Enterprise build (or an explicit caller) supplies a key.
11//!
12//! Pure: no I/O, no network — the verify path stays inside mati's zero-network
13//! invariant. Mirrors the frozen-canonical + Ed25519 envelope convention used
14//! by mati-cloud's `mati_license`.
15
16use base64::engine::general_purpose::STANDARD as B64;
17use base64::Engine;
18use ed25519_dalek::{Signature, Verifier, VerifyingKey};
19use serde::{Deserialize, Serialize};
20
21/// The accepted on-wire bundle format version.
22pub const POLICY_FORMAT_VERSION: u32 = 1;
23
24/// On-wire policy bundle: a signed payload of floor rules.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct PolicyBundle {
27    /// Frozen on-wire format version. `1` for the layout below.
28    pub policy_format_version: u32,
29    /// Signature algorithm. Only `"ed25519"` is accepted.
30    pub alg: String,
31    /// Identifies which trusted key signed this bundle (no key fallback).
32    pub key_id: String,
33    /// Who issued the bundle (e.g. the org id). Informational.
34    pub issuer: String,
35    pub payload: PolicyPayload,
36    /// Base64 (standard) Ed25519 signature over the canonical payload bytes.
37    pub signature: String,
38}
39
40/// The signed content: org identity + the floor rules.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct PolicyPayload {
43    pub org_id: String,
44    pub bundle_id: String,
45    /// ISO-8601 issue timestamp (informational; expiry handling is a follow-up).
46    pub issued_at: String,
47    pub rules: Vec<PolicyRule>,
48}
49
50/// A single non-weakenable floor rule. How `hook-decide` honors it is wired in
51/// a follow-up; this defines the verified shape.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct PolicyRule {
54    pub id: String,
55    /// Repo-relative path or glob the rule applies to.
56    pub target: String,
57    /// Floor level: `"deny"` (hard block) or `"advisory"` (inject context).
58    pub level: String,
59    pub reason: String,
60}
61
62/// A trusted signer: a key id and its Ed25519 public key.
63#[derive(Debug, Clone)]
64pub struct TrustedKey {
65    pub key_id: String,
66    pub public_key: [u8; 32],
67}
68
69/// Trusted policy signers embedded in THIS build. **Empty in the OSS core**, so
70/// no bundle verifies and the floor is dormant — the open-core boundary. An
71/// Enterprise build supplies its vendor/org key(s) here; a caller may also pass
72/// its own to [`verify_bundle`] (e.g. `mati policy verify --key`).
73pub fn default_trusted_keys() -> Vec<TrustedKey> {
74    Vec::new()
75}
76
77#[derive(Debug, thiserror::Error, PartialEq, Eq)]
78pub enum PolicyError {
79    #[error("unsupported policy_format_version {0} (expected {POLICY_FORMAT_VERSION})")]
80    UnsupportedVersion(u32),
81    #[error("unsupported signature algorithm {0:?} (expected \"ed25519\")")]
82    UnsupportedAlg(String),
83    #[error("no trusted key matches key_id {0:?}")]
84    UnknownKey(String),
85    #[error("policy bundle signature verification failed (corrupt, tampered, or wrong key)")]
86    SignatureInvalid,
87}
88
89/// A bundle whose signature verified against a trusted key. Holds the rules the
90/// caller may honor as a floor.
91#[derive(Debug, Clone)]
92pub struct VerifiedBundle {
93    pub org_id: String,
94    pub bundle_id: String,
95    pub rules: Vec<PolicyRule>,
96}
97
98/// Canonical bytes of a payload for signing/verification. Field order is FROZEN
99/// for `policy_format_version` 1 — changing it invalidates every existing
100/// signature. Re-serializes through an explicit-order struct rather than
101/// trusting the incoming JSON's field/key order.
102fn canonical_payload_bytes(payload: &PolicyPayload) -> Vec<u8> {
103    #[derive(Serialize)]
104    struct CanonicalRule<'a> {
105        id: &'a str,
106        target: &'a str,
107        level: &'a str,
108        reason: &'a str,
109    }
110    #[derive(Serialize)]
111    struct Canonical<'a> {
112        org_id: &'a str,
113        bundle_id: &'a str,
114        issued_at: &'a str,
115        rules: Vec<CanonicalRule<'a>>,
116    }
117    let canonical = Canonical {
118        org_id: &payload.org_id,
119        bundle_id: &payload.bundle_id,
120        issued_at: &payload.issued_at,
121        rules: payload
122            .rules
123            .iter()
124            .map(|r| CanonicalRule {
125                id: &r.id,
126                target: &r.target,
127                level: &r.level,
128                reason: &r.reason,
129            })
130            .collect(),
131    };
132    serde_json::to_vec(&canonical).expect("canonical serialization cannot fail")
133}
134
135/// Verify a policy bundle against trusted keys.
136///
137/// The signature MUST verify against the exact key named by `key_id` — there is
138/// NO fallback to other trusted keys, so a bundle signed with key A never
139/// verifies against key B even if both are trusted. Returns the verified rules,
140/// or a [`PolicyError`] describing the rejection. Pure + offline.
141pub fn verify_bundle(
142    bundle: &PolicyBundle,
143    trusted_keys: &[TrustedKey],
144) -> Result<VerifiedBundle, PolicyError> {
145    if bundle.policy_format_version != POLICY_FORMAT_VERSION {
146        return Err(PolicyError::UnsupportedVersion(
147            bundle.policy_format_version,
148        ));
149    }
150    if bundle.alg != "ed25519" {
151        return Err(PolicyError::UnsupportedAlg(bundle.alg.clone()));
152    }
153
154    let trusted = trusted_keys
155        .iter()
156        .find(|k| k.key_id == bundle.key_id)
157        .ok_or_else(|| PolicyError::UnknownKey(bundle.key_id.clone()))?;
158
159    let verifying =
160        VerifyingKey::from_bytes(&trusted.public_key).map_err(|_| PolicyError::SignatureInvalid)?;
161
162    let sig_bytes = B64
163        .decode(bundle.signature.as_bytes())
164        .map_err(|_| PolicyError::SignatureInvalid)?;
165    let sig_arr: [u8; 64] = sig_bytes
166        .as_slice()
167        .try_into()
168        .map_err(|_| PolicyError::SignatureInvalid)?;
169    let signature = Signature::from_bytes(&sig_arr);
170
171    let canonical = canonical_payload_bytes(&bundle.payload);
172    verifying
173        .verify(&canonical, &signature)
174        .map_err(|_| PolicyError::SignatureInvalid)?;
175
176    Ok(VerifiedBundle {
177        org_id: bundle.payload.org_id.clone(),
178        bundle_id: bundle.payload.bundle_id.clone(),
179        rules: bundle.payload.rules.clone(),
180    })
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use ed25519_dalek::{Signer, SigningKey};
187
188    const TEST_SEED: [u8; 32] = [7u8; 32];
189
190    fn test_key() -> (SigningKey, TrustedKey) {
191        let sk = SigningKey::from_bytes(&TEST_SEED);
192        let public_key = sk.verifying_key().to_bytes();
193        (
194            sk,
195            TrustedKey {
196                key_id: "test-key-1".into(),
197                public_key,
198            },
199        )
200    }
201
202    fn sample_payload() -> PolicyPayload {
203        PolicyPayload {
204            org_id: "acme".into(),
205            bundle_id: "b-001".into(),
206            issued_at: "2026-06-28T00:00:00Z".into(),
207            rules: vec![PolicyRule {
208                id: "PHI-1".into(),
209                target: "src/payments/**".into(),
210                level: "deny".into(),
211                reason: "PHI files require consultation".into(),
212            }],
213        }
214    }
215
216    fn sign_with(sk: &SigningKey, key_id: &str, payload: PolicyPayload) -> PolicyBundle {
217        let sig = sk.sign(&canonical_payload_bytes(&payload));
218        PolicyBundle {
219            policy_format_version: POLICY_FORMAT_VERSION,
220            alg: "ed25519".into(),
221            key_id: key_id.to_string(),
222            issuer: "acme".into(),
223            payload,
224            signature: B64.encode(sig.to_bytes()),
225        }
226    }
227
228    #[test]
229    fn accepts_a_correctly_signed_bundle() {
230        let (sk, trusted) = test_key();
231        let bundle = sign_with(&sk, &trusted.key_id, sample_payload());
232        let verified = verify_bundle(&bundle, &[trusted]).expect("should verify");
233        assert_eq!(verified.org_id, "acme");
234        assert_eq!(verified.rules.len(), 1);
235        assert_eq!(verified.rules[0].id, "PHI-1");
236        assert_eq!(verified.rules[0].level, "deny");
237    }
238
239    #[test]
240    fn rejects_a_tampered_payload() {
241        // The whole point of a floor: weakening a rule after signing must fail.
242        let (sk, trusted) = test_key();
243        let mut bundle = sign_with(&sk, &trusted.key_id, sample_payload());
244        bundle.payload.rules[0].level = "advisory".into(); // weaken deny -> advisory
245        assert!(matches!(
246            verify_bundle(&bundle, &[trusted]),
247            Err(PolicyError::SignatureInvalid)
248        ));
249    }
250
251    #[test]
252    fn rejects_an_unknown_key_id() {
253        let (sk, trusted) = test_key();
254        let bundle = sign_with(&sk, "some-other-key", sample_payload());
255        assert!(matches!(
256            verify_bundle(&bundle, &[trusted]),
257            Err(PolicyError::UnknownKey(_))
258        ));
259    }
260
261    #[test]
262    fn rejects_a_signature_from_an_untrusted_key() {
263        // Attacker signs with their own key but claims the trusted key_id.
264        let (_sk, trusted) = test_key();
265        let attacker = SigningKey::from_bytes(&[9u8; 32]);
266        let payload = sample_payload();
267        let sig = attacker.sign(&canonical_payload_bytes(&payload));
268        let bundle = PolicyBundle {
269            policy_format_version: POLICY_FORMAT_VERSION,
270            alg: "ed25519".into(),
271            key_id: trusted.key_id.clone(),
272            issuer: "acme".into(),
273            payload,
274            signature: B64.encode(sig.to_bytes()),
275        };
276        assert!(matches!(
277            verify_bundle(&bundle, &[trusted]),
278            Err(PolicyError::SignatureInvalid)
279        ));
280    }
281
282    #[test]
283    fn rejects_bad_version_and_alg() {
284        let (sk, trusted) = test_key();
285        let mut v = sign_with(&sk, &trusted.key_id, sample_payload());
286        v.policy_format_version = 999;
287        assert!(matches!(
288            verify_bundle(&v, std::slice::from_ref(&trusted)),
289            Err(PolicyError::UnsupportedVersion(999))
290        ));
291
292        let mut a = sign_with(&sk, &trusted.key_id, sample_payload());
293        a.alg = "rsa".into();
294        assert!(matches!(
295            verify_bundle(&a, &[trusted]),
296            Err(PolicyError::UnsupportedAlg(_))
297        ));
298    }
299
300    #[test]
301    fn oss_core_trusts_no_keys_so_floor_is_dormant() {
302        // Open-core gate: with the default (empty) trust anchor, even a
303        // perfectly-signed bundle is rejected — the mechanism is inert until an
304        // Enterprise build (or explicit caller) supplies a trusted key.
305        let (sk, trusted) = test_key();
306        let bundle = sign_with(&sk, &trusted.key_id, sample_payload());
307        assert!(matches!(
308            verify_bundle(&bundle, &default_trusted_keys()),
309            Err(PolicyError::UnknownKey(_))
310        ));
311    }
312}