Skip to main content

mur_common/
official.rs

1//! Official catalog license — pure types + signing/verification (no I/O).
2//!
3//! Model mirrors `fleet_bundle.rs`: canonical sign input = the struct
4//! serialized as JSON with `sig` cleared; Ed25519 over those bytes. The
5//! license binds an official catalog item to one app.mur.run account.
6//! Expiry gates downloads/updates only — never installed content.
7
8use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
9use serde::{Deserialize, Serialize};
10
11use crate::muragent::dsse::keyid_from_pubkey;
12
13/// License wire-format version. Bump on any breaking change.
14pub const OFFICIAL_LICENSE_FORMAT: u32 = 1;
15
16/// Value of the `distribution` marker stamped inside official bundle manifests.
17pub const DISTRIBUTION_OFFICIAL: &str = "official";
18
19/// A signed record binding an official catalog item to one app.mur.run account.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct OfficialLicense {
22    pub format_version: u32,
23    /// app.mur.run account id the license is bound to.
24    pub user_id: String,
25    /// Catalog item id, e.g. `agents/researcher` or `fleets/deep-research`.
26    pub item: String,
27    /// Item version this license was issued for.
28    pub version: String,
29    /// RFC3339 expiry (subscription end + grace). Gates downloads only.
30    pub expires_at: String,
31    /// Signer's Ed25519 public key, base64 (32 bytes).
32    pub signer_pubkey: String,
33    /// Base64 Ed25519 signature over `license_sign_input`. `None` = unsigned.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub sig: Option<String>,
36}
37
38/// Canonical signing input: the license serialized with `sig` cleared.
39pub fn license_sign_input(l: &OfficialLicense) -> Vec<u8> {
40    let mut unsigned = l.clone();
41    unsigned.sig = None;
42    serde_json::to_vec(&unsigned).expect("license serializes")
43}
44
45/// Sign in place: fills `signer_pubkey` from `key` and sets `sig`.
46pub fn sign_license(l: &mut OfficialLicense, key: &SigningKey) {
47    use base64::{Engine, engine::general_purpose::STANDARD as B64};
48    l.signer_pubkey = B64.encode(key.verifying_key().as_bytes());
49    l.sig = None;
50    let sig: Signature = key.sign(&license_sign_input(l));
51    l.sig = Some(B64.encode(sig.to_bytes()));
52}
53
54/// Verify `sig` against the embedded `signer_pubkey`. False on any failure.
55pub fn verify_license_sig(l: &OfficialLicense) -> bool {
56    use base64::{Engine, engine::general_purpose::STANDARD as B64};
57    let Some(sig_b64) = &l.sig else { return false };
58    let Ok(pk_bytes) = B64.decode(&l.signer_pubkey) else {
59        return false;
60    };
61    let Ok(pk_arr) = <[u8; 32]>::try_from(pk_bytes.as_slice()) else {
62        return false;
63    };
64    let Ok(vk) = VerifyingKey::from_bytes(&pk_arr) else {
65        return false;
66    };
67    let Ok(sig_bytes) = B64.decode(sig_b64) else {
68        return false;
69    };
70    let Ok(sig_arr) = <[u8; 64]>::try_from(sig_bytes.as_slice()) else {
71        return false;
72    };
73    vk.verify(&license_sign_input(l), &Signature::from_bytes(&sig_arr))
74        .is_ok()
75}
76
77/// Publisher-trust-style fingerprint (`ed25519-<8hex>`) of the embedded key.
78pub fn license_key_fp(l: &OfficialLicense) -> Option<String> {
79    use base64::{Engine, engine::general_purpose::STANDARD as B64};
80    let pk = B64.decode(&l.signer_pubkey).ok()?;
81    let arr = <[u8; 32]>::try_from(pk.as_slice()).ok()?;
82    Some(keyid_from_pubkey(&arr))
83}
84
85/// Outcome of a full license check. Order of checks: signature → signer
86/// identity → item binding → user binding (fail-closed at the first miss).
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum LicenseCheck {
89    Ok,
90    BadSignature,
91    NotOfficialKey,
92    WrongUser,
93    WrongItem,
94}
95
96/// Full check against an expected item + logged-in user + official key fp.
97/// `official_fp` is a parameter for testability; production callers pass
98/// `MUR_OFFICIAL_PUBLISHER_KEY_FP`. Expiry is deliberately NOT checked here.
99pub fn check_license(
100    l: &OfficialLicense,
101    expected_item: &str,
102    user_id: &str,
103    official_fp: &str,
104) -> LicenseCheck {
105    if !verify_license_sig(l) {
106        return LicenseCheck::BadSignature;
107    }
108    match license_key_fp(l) {
109        Some(fp) if fp == official_fp => {}
110        _ => return LicenseCheck::NotOfficialKey,
111    }
112    if l.item != expected_item {
113        return LicenseCheck::WrongItem;
114    }
115    if l.user_id != user_id {
116        return LicenseCheck::WrongUser;
117    }
118    LicenseCheck::Ok
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use ed25519_dalek::SigningKey;
125
126    fn test_license(key: &SigningKey) -> OfficialLicense {
127        let mut l = OfficialLicense {
128            format_version: OFFICIAL_LICENSE_FORMAT,
129            user_id: "user-123".into(),
130            item: "fleets/deep-research".into(),
131            version: "1.0.0".into(),
132            expires_at: "2027-01-01T00:00:00Z".into(),
133            signer_pubkey: String::new(),
134            sig: None,
135        };
136        sign_license(&mut l, key);
137        l
138    }
139
140    #[test]
141    fn sign_verify_roundtrip() {
142        let key = SigningKey::from_bytes(&[7u8; 32]);
143        let l = test_license(&key);
144        assert!(verify_license_sig(&l));
145    }
146
147    #[test]
148    fn tampered_field_fails_verify() {
149        let key = SigningKey::from_bytes(&[7u8; 32]);
150        let mut l = test_license(&key);
151        l.user_id = "someone-else".into();
152        assert!(!verify_license_sig(&l));
153    }
154
155    #[test]
156    fn unsigned_fails_verify() {
157        let key = SigningKey::from_bytes(&[7u8; 32]);
158        let mut l = test_license(&key);
159        l.sig = None;
160        assert!(!verify_license_sig(&l));
161    }
162
163    #[test]
164    fn check_license_matrix() {
165        let key = SigningKey::from_bytes(&[7u8; 32]);
166        let l = test_license(&key);
167        let fp = license_key_fp(&l).unwrap();
168        assert_eq!(
169            check_license(&l, "fleets/deep-research", "user-123", &fp),
170            LicenseCheck::Ok
171        );
172        assert_eq!(
173            check_license(&l, "fleets/deep-research", "user-999", &fp),
174            LicenseCheck::WrongUser
175        );
176        assert_eq!(
177            check_license(&l, "fleets/other", "user-123", &fp),
178            LicenseCheck::WrongItem
179        );
180        assert_eq!(
181            check_license(&l, "fleets/deep-research", "user-123", "ed25519-ffffffff"),
182            LicenseCheck::NotOfficialKey
183        );
184        let mut bad = l.clone();
185        bad.sig = Some("mtampered".into());
186        assert_eq!(
187            check_license(&bad, "fleets/deep-research", "user-123", &fp),
188            LicenseCheck::BadSignature
189        );
190    }
191}