Skip to main content

lean_ctx_protocol/
entitlement.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Bounded, canonical entitlement wire contract, not an authorization decision.
3//!
4//! Callers must separately verify the signature against trusted keys, account /
5//! workspace / deployment binding, revocation, and current-time policy. Plain
6//! serde decoding and public-field construction do not validate the envelope.
7
8use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use crate::{Sha256Digest, ValidationError, deserialize_schema_version, validate_schema_version};
14
15pub const ENTITLEMENT_SIGNATURE_DOMAIN: &[u8] = b"leanctx/entitlement/v1\0";
16pub const MAX_ENTITLEMENT_BYTES: usize = 32 * 1024;
17pub const MAX_ENTITLEMENT_CAPABILITIES: usize = 256;
18pub const MAX_ENTITLEMENT_CAPABILITY_BYTES: usize = 128;
19pub const MAX_ENTITLEMENT_IDENTIFIER_BYTES: usize = 256;
20pub const MAX_ENTITLEMENT_TIMESTAMP: u64 = 9_007_199_254_740_991;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum EntitlementKindV1 {
25    OnlineSubscription,
26    OfflineEnterprise,
27}
28
29/// Canonical product tiers only; commercial SKU aliases never enter this wire.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum EntitlementPlanV1 {
33    Community,
34    Pro,
35    Team,
36    Enterprise,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum EntitlementDeploymentV1 {
42    Hosted,
43    SelfHosted,
44    AirGapped,
45}
46
47/// Signed key metadata, never an embedded trust anchor or private key.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct EntitlementSignerV1 {
51    pub algorithm: String,
52    pub key_id: String,
53    pub public_key_digest: Sha256Digest,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct EntitlementEnvelopeV1 {
59    #[serde(deserialize_with = "deserialize_schema_version")]
60    pub schema_version: u32,
61    pub entitlement_id: String,
62    pub kind: EntitlementKindV1,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub account_id: Option<String>,
65    pub plan: EntitlementPlanV1,
66    pub seats: u32,
67    pub capabilities: Vec<String>,
68    pub issued_at: u64,
69    pub not_before: u64,
70    pub expires_at: u64,
71    pub grace_until: u64,
72    pub allowed_deployments: Vec<EntitlementDeploymentV1>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub deployment_id: Option<String>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub org_id: Option<String>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub workspace_id: Option<String>,
79    pub signer: EntitlementSignerV1,
80    pub signature: String,
81}
82
83impl EntitlementEnvelopeV1 {
84    pub const SCHEMA_VERSION: u32 = 1;
85
86    /// Decode one bounded canonical JSON object, rejecting duplicates at every
87    /// object level, unknown fields, malformed UTF-8 and noncanonical spelling.
88    /// This proves structure only, never signature authenticity or authorization.
89    pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, ValidationError> {
90        if bytes.len() > MAX_ENTITLEMENT_BYTES {
91            return Err(ValidationError::new("entitlement exceeds 32 KiB"));
92        }
93        // Direct typed deserialization preserves serde's duplicate-field checks;
94        // an intermediate Value would silently collapse duplicate object keys.
95        let envelope: Self = serde_json::from_slice(bytes)
96            .map_err(|_| ValidationError::new("invalid entitlement JSON or field shape"))?;
97        if envelope.canonical_bytes()? != bytes {
98            return Err(ValidationError::new("entitlement JSON is not canonical"));
99        }
100        Ok(envelope)
101    }
102
103    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ValidationError> {
104        Self::from_canonical_bytes(bytes)
105    }
106
107    /// Compact UTF-8 JSON with recursively lexical object keys, signature included.
108    pub fn canonical_bytes(&self) -> Result<Vec<u8>, ValidationError> {
109        self.validate()?;
110        canonical_value(self, false)
111    }
112
113    /// Every field except signature; permits an empty signature before signing.
114    pub fn unsigned_canonical_bytes(&self) -> Result<Vec<u8>, ValidationError> {
115        self.validate_unsigned()?;
116        canonical_value(self, true)
117    }
118
119    /// Ed25519 input: versioned domain including its NUL, then unsigned JSON.
120    pub fn signing_bytes(&self) -> Result<Vec<u8>, ValidationError> {
121        let unsigned = self.unsigned_canonical_bytes()?;
122        let mut bytes = Vec::with_capacity(ENTITLEMENT_SIGNATURE_DOMAIN.len() + unsigned.len());
123        bytes.extend_from_slice(ENTITLEMENT_SIGNATURE_DOMAIN);
124        bytes.extend_from_slice(&unsigned);
125        Ok(bytes)
126    }
127
128    pub fn validate(&self) -> Result<(), ValidationError> {
129        self.validate_unsigned()?;
130        let bytes = self.signature.as_bytes();
131        // 64 bytes have 86 meaningful RFC 4648 characters followed by ==.
132        // The final sextet's low four bits must be zero (A, Q, g or w).
133        if bytes.len() != 88
134            || &bytes[86..] != b"=="
135            || !bytes[..86]
136                .iter()
137                .all(|b| b.is_ascii_alphanumeric() || *b == b'+' || *b == b'/')
138            || !matches!(bytes[85], b'A' | b'Q' | b'g' | b'w')
139        {
140            return Err(ValidationError::new(
141                "entitlement signature must be canonical base64 of 64 Ed25519 bytes",
142            ));
143        }
144        Ok(())
145    }
146
147    fn validate_unsigned(&self) -> Result<(), ValidationError> {
148        validate_schema_version(self.schema_version)?;
149        validate_id(
150            &self.entitlement_id,
151            "entitlement_id",
152            MAX_ENTITLEMENT_IDENTIFIER_BYTES,
153        )?;
154        for (field, value) in [
155            ("account_id", &self.account_id),
156            ("deployment_id", &self.deployment_id),
157            ("org_id", &self.org_id),
158            ("workspace_id", &self.workspace_id),
159        ] {
160            if let Some(value) = value {
161                validate_id(value, field, MAX_ENTITLEMENT_IDENTIFIER_BYTES)?;
162            }
163        }
164        if self.seats == 0 {
165            return Err(ValidationError::new("entitlement seats must be positive"));
166        }
167        if self.capabilities.len() > MAX_ENTITLEMENT_CAPABILITIES {
168            return Err(ValidationError::new(
169                "entitlement has more than 256 capabilities",
170            ));
171        }
172        for capability in &self.capabilities {
173            validate_id(capability, "capability", MAX_ENTITLEMENT_CAPABILITY_BYTES)?;
174        }
175        if self.capabilities.windows(2).any(|pair| pair[0] >= pair[1]) {
176            return Err(ValidationError::new(
177                "entitlement capabilities must be sorted and unique",
178            ));
179        }
180        if [
181            self.issued_at,
182            self.not_before,
183            self.expires_at,
184            self.grace_until,
185        ]
186        .into_iter()
187        .any(|time| time > MAX_ENTITLEMENT_TIMESTAMP)
188        {
189            return Err(ValidationError::new(
190                "entitlement timestamp exceeds exact JSON integer range",
191            ));
192        }
193        if !(self.not_before <= self.issued_at
194            && self.issued_at < self.expires_at
195            && self.expires_at <= self.grace_until)
196        {
197            return Err(ValidationError::new("entitlement time window is invalid"));
198        }
199        if self.allowed_deployments.is_empty()
200            || self.allowed_deployments.len() > 3
201            || self
202                .allowed_deployments
203                .iter()
204                .enumerate()
205                .any(|(i, value)| self.allowed_deployments[..i].contains(value))
206        {
207            return Err(ValidationError::new(
208                "allowed deployments must be nonempty and unique",
209            ));
210        }
211        match self.kind {
212            EntitlementKindV1::OnlineSubscription if self.account_id.is_none() => {
213                return Err(ValidationError::new(
214                    "online entitlement requires account_id",
215                ));
216            }
217            EntitlementKindV1::OfflineEnterprise
218                if self.plan != EntitlementPlanV1::Enterprise
219                    || self.deployment_id.is_none()
220                    || self
221                        .allowed_deployments
222                        .contains(&EntitlementDeploymentV1::Hosted) =>
223            {
224                return Err(ValidationError::new(
225                    "offline entitlement requires Enterprise, deployment_id and no hosted deployment",
226                ));
227            }
228            _ => {}
229        }
230        if self.signer.algorithm != "ed25519" {
231            return Err(ValidationError::new(
232                "entitlement signer algorithm must be ed25519",
233            ));
234        }
235        validate_id(
236            &self.signer.key_id,
237            "key_id",
238            MAX_ENTITLEMENT_IDENTIFIER_BYTES,
239        )?;
240        // The digest newtype already enforces canonical sha256:<64 lowercase hex>.
241        // Reserve the exact valid signature member size even before signing.
242        if canonical_value(self, true)?.len() + b",\"signature\":\"\"".len() + 88
243            > MAX_ENTITLEMENT_BYTES
244        {
245            return Err(ValidationError::new("entitlement exceeds 32 KiB"));
246        }
247        Ok(())
248    }
249}
250
251fn validate_id(value: &str, field: &str, max_bytes: usize) -> Result<(), ValidationError> {
252    if value.is_empty() || value.len() > max_bytes || !value.bytes().all(|b| b.is_ascii_graphic()) {
253        return Err(ValidationError::new(format!(
254            "{field} must be 1..={max_bytes} printable ASCII bytes without whitespace"
255        )));
256    }
257    Ok(())
258}
259
260fn canonical_value(
261    envelope: &EntitlementEnvelopeV1,
262    unsigned: bool,
263) -> Result<Vec<u8>, ValidationError> {
264    let mut value = serde_json::to_value(envelope)
265        .map_err(|_| ValidationError::new("cannot serialize entitlement"))?;
266    if unsigned {
267        let Value::Object(object) = &mut value else {
268            return Err(ValidationError::new("entitlement must be an object"));
269        };
270        object.remove("signature");
271    }
272    serde_json::to_vec(&sort_json(value))
273        .map_err(|_| ValidationError::new("cannot canonicalize entitlement"))
274}
275
276// Match the existing protocol canonicalization, including when Cargo feature
277// unification enables serde_json/preserve_order in a consuming application.
278pub(crate) fn sort_json(value: Value) -> Value {
279    match value {
280        Value::Object(object) => Value::Object(
281            object
282                .into_iter()
283                .map(|(key, value)| (key, sort_json(value)))
284                .collect::<BTreeMap<_, _>>()
285                .into_iter()
286                .collect(),
287        ),
288        Value::Array(values) => Value::Array(values.into_iter().map(sort_json).collect()),
289        scalar => scalar,
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::{
296        ENTITLEMENT_SIGNATURE_DOMAIN, EntitlementDeploymentV1, EntitlementEnvelopeV1,
297        EntitlementKindV1, EntitlementPlanV1, EntitlementSignerV1, MAX_ENTITLEMENT_BYTES,
298        MAX_ENTITLEMENT_TIMESTAMP,
299    };
300    use crate::Sha256Digest;
301
302    fn envelope() -> EntitlementEnvelopeV1 {
303        EntitlementEnvelopeV1 {
304            schema_version: 1,
305            entitlement_id: "ent-1".into(),
306            kind: EntitlementKindV1::OnlineSubscription,
307            account_id: Some("account-1".into()),
308            plan: EntitlementPlanV1::Pro,
309            seats: 1,
310            capabilities: vec!["context.autopilot".into(), "execution.autopilot".into()],
311            issued_at: 100,
312            not_before: 99,
313            expires_at: 200,
314            grace_until: 210,
315            allowed_deployments: vec![EntitlementDeploymentV1::Hosted],
316            deployment_id: None,
317            org_id: None,
318            workspace_id: None,
319            signer: EntitlementSignerV1 {
320                algorithm: "ed25519".into(),
321                key_id: "key-1".into(),
322                public_key_digest: Sha256Digest::new(format!("sha256:{}", "a".repeat(64))).unwrap(),
323            },
324            signature: "A".repeat(86) + "==",
325        }
326    }
327
328    #[test]
329    fn golden_canonical_unsigned_domain_and_round_trip() {
330        let envelope = envelope();
331        let expected = concat!(
332            "{\"account_id\":\"account-1\",\"allowed_deployments\":[\"hosted\"],",
333            "\"capabilities\":[\"context.autopilot\",\"execution.autopilot\"],",
334            "\"entitlement_id\":\"ent-1\",\"expires_at\":200,\"grace_until\":210,",
335            "\"issued_at\":100,\"kind\":\"online_subscription\",\"not_before\":99,",
336            "\"plan\":\"pro\",\"schema_version\":1,\"seats\":1,",
337            "\"signer\":{\"algorithm\":\"ed25519\",\"key_id\":\"key-1\",",
338            "\"public_key_digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}}"
339        );
340        assert_eq!(
341            envelope.unsigned_canonical_bytes().unwrap(),
342            expected.as_bytes()
343        );
344        let mut signing = b"leanctx/entitlement/v1\0".to_vec();
345        signing.extend_from_slice(expected.as_bytes());
346        assert_eq!(envelope.signing_bytes().unwrap(), signing);
347        assert_eq!(ENTITLEMENT_SIGNATURE_DOMAIN.last(), Some(&0));
348        let bytes = envelope.canonical_bytes().unwrap();
349        assert_eq!(EntitlementEnvelopeV1::from_bytes(&bytes).unwrap(), envelope);
350    }
351
352    #[test]
353    fn duplicate_unknown_nested_and_optional_fields_are_rejected() {
354        let bytes = envelope().canonical_bytes().unwrap();
355        let canonical = String::from_utf8(bytes).unwrap();
356        for bad in [
357            canonical.replacen("{", "{\"schema_version\":1,", 1),
358            canonical.replacen("{", "{\"future\":true,", 1),
359            canonical.replace("\"algorithm\":", "\"algorithm\":\"ed25519\",\"algorithm\":"),
360            canonical.replace("\"algorithm\":", "\"unknown\":0,\"algorithm\":"),
361            canonical.replacen("{", "{\"org_id\":null,\"org_id\":null,", 1),
362            canonical.replacen("{", "{\"account_id\":null,", 1),
363        ] {
364            assert!(EntitlementEnvelopeV1::from_bytes(bad.as_bytes()).is_err());
365            // The typed serde path itself rejects duplicates/unknowns, not just
366            // the subsequent canonical comparison.
367            assert!(serde_json::from_str::<EntitlementEnvelopeV1>(&bad).is_err());
368        }
369    }
370
371    #[test]
372    fn strict_decode_rejects_noncanonical_utf8_trailing_and_oversize_input() {
373        let canonical = envelope().canonical_bytes().unwrap();
374        let mut spaced = canonical.clone();
375        spaced.push(b'\n');
376        let mut trailing = canonical.clone();
377        trailing.extend_from_slice(b"{}");
378        for invalid in [
379            spaced,
380            trailing,
381            vec![0xff],
382            vec![b' '; MAX_ENTITLEMENT_BYTES + 1],
383        ] {
384            assert!(EntitlementEnvelopeV1::from_bytes(&invalid).is_err());
385        }
386        let text = String::from_utf8(canonical).unwrap();
387        for invalid in [
388            text.replace("account-1", "account\\u002d1"),
389            text.replacen("{", "{\"org_id\":null,", 1),
390            text.replace("\"seats\":1", "\"seats\":1.0"),
391            text.replace("\"issued_at\":100", "\"issued_at\":1e2"),
392            text.replace("\"issued_at\":100", "\"issued_at\":-1"),
393            text.replace("\"schema_version\":1", "\"schema_version\":1.0"),
394        ] {
395            assert!(EntitlementEnvelopeV1::from_bytes(invalid.as_bytes()).is_err());
396        }
397    }
398
399    #[test]
400    fn only_four_canonical_plans_and_two_kinds_are_decoded() {
401        for plan in [
402            EntitlementPlanV1::Community,
403            EntitlementPlanV1::Pro,
404            EntitlementPlanV1::Team,
405            EntitlementPlanV1::Enterprise,
406        ] {
407            let mut value = envelope();
408            value.plan = plan;
409            assert_eq!(
410                EntitlementEnvelopeV1::from_bytes(&value.canonical_bytes().unwrap())
411                    .unwrap()
412                    .plan,
413                plan
414            );
415        }
416        let bytes = String::from_utf8(envelope().canonical_bytes().unwrap()).unwrap();
417        for plan in ["business", "biz", "supporter", "sponsor", "Pro", "unknown"] {
418            assert!(
419                EntitlementEnvelopeV1::from_bytes(
420                    bytes.replace("\"pro\"", &format!("\"{plan}\"")).as_bytes()
421                )
422                .is_err()
423            );
424        }
425        assert!(
426            EntitlementEnvelopeV1::from_bytes(
427                bytes.replace("online_subscription", "trial").as_bytes()
428            )
429            .is_err()
430        );
431    }
432
433    #[test]
434    fn online_and_offline_bindings_are_distinct() {
435        let mut value = envelope();
436        value.account_id = None;
437        assert!(value.validate().is_err());
438        value.kind = EntitlementKindV1::OfflineEnterprise;
439        value.plan = EntitlementPlanV1::Enterprise;
440        value.deployment_id = Some("deployment-1".into());
441        assert!(value.validate().is_err()); // hosted forbidden offline
442        value.allowed_deployments = vec![
443            EntitlementDeploymentV1::SelfHosted,
444            EntitlementDeploymentV1::AirGapped,
445        ];
446        value.validate().unwrap();
447        assert_eq!(
448            EntitlementEnvelopeV1::from_bytes(&value.canonical_bytes().unwrap()).unwrap(),
449            value
450        );
451        value.plan = EntitlementPlanV1::Team;
452        assert!(value.validate().is_err());
453        value.plan = EntitlementPlanV1::Enterprise;
454        value.deployment_id = None;
455        assert!(value.validate().is_err());
456    }
457
458    #[test]
459    fn deployment_sets_are_nonempty_unique_and_known() {
460        let mut value = envelope();
461        value.allowed_deployments.clear();
462        assert!(value.validate().is_err());
463        value.allowed_deployments = vec![EntitlementDeploymentV1::Hosted; 2];
464        assert!(value.validate().is_err());
465        let bytes = String::from_utf8(envelope().canonical_bytes().unwrap()).unwrap();
466        assert!(
467            EntitlementEnvelopeV1::from_bytes(
468                bytes.replace("\"hosted\"", "\"anything\"").as_bytes()
469            )
470            .is_err()
471        );
472    }
473
474    #[test]
475    fn identifier_and_capability_ascii_length_order_bounds() {
476        for invalid in [
477            "".to_string(),
478            "a b".into(),
479            "a\n".into(),
480            "é".into(),
481            "\u{7f}".into(),
482            "x".repeat(257),
483        ] {
484            for field in 0..6 {
485                let mut value = envelope();
486                match field {
487                    0 => value.entitlement_id = invalid.clone(),
488                    1 => value.account_id = Some(invalid.clone()),
489                    2 => value.deployment_id = Some(invalid.clone()),
490                    3 => value.org_id = Some(invalid.clone()),
491                    4 => value.workspace_id = Some(invalid.clone()),
492                    _ => value.signer.key_id = invalid.clone(),
493                }
494                assert!(value.validate().is_err());
495            }
496        }
497        let mut value = envelope();
498        value.entitlement_id = "x".repeat(256);
499        value.capabilities = vec!["x".repeat(128)];
500        value.validate().unwrap();
501        for invalid in [
502            vec!["x".repeat(129)],
503            vec!["é".into()],
504            vec!["".into()],
505            vec!["a b".into()],
506            vec!["a".into(), "a".into()],
507            vec!["z".into(), "a".into()],
508        ] {
509            value.capabilities = invalid;
510            assert!(value.validate().is_err());
511        }
512        value.capabilities = (0..256).map(|i| format!("cap-{i:03}")).collect();
513        value.validate().unwrap();
514        value.capabilities.push("cap-256".into());
515        assert!(value.validate().is_err());
516        value.capabilities.clear(); // empty is structural, not a paid-feature grant
517        value.validate().unwrap();
518    }
519
520    #[test]
521    fn combined_byte_bound_applies_even_with_individually_valid_capabilities() {
522        let mut value = envelope();
523        value.capabilities = (0..256)
524            .map(|i| format!("{i:03}{}", "x".repeat(125)))
525            .collect();
526        assert!(value.validate().is_err());
527        assert!(value.signing_bytes().is_err());
528        assert!(value.canonical_bytes().is_err());
529    }
530
531    #[test]
532    fn exact_full_envelope_byte_boundary_includes_signature_member() {
533        let mut value = envelope();
534        value.capabilities = (0..256).map(|i| format!("{i:03}")).collect();
535        let mut remaining = MAX_ENTITLEMENT_BYTES - value.canonical_bytes().unwrap().len();
536        for capability in &mut value.capabilities {
537            let extra = remaining.min(128 - capability.len());
538            capability.push_str(&"x".repeat(extra));
539            remaining -= extra;
540        }
541        assert_eq!(remaining, 0);
542        let bytes = value.canonical_bytes().unwrap();
543        assert_eq!(bytes.len(), MAX_ENTITLEMENT_BYTES);
544        assert_eq!(EntitlementEnvelopeV1::from_bytes(&bytes).unwrap(), value);
545        value
546            .capabilities
547            .iter_mut()
548            .find(|capability| capability.len() < 128)
549            .unwrap()
550            .push('x');
551        assert!(value.validate().is_err());
552        assert!(value.unsigned_canonical_bytes().is_err());
553    }
554
555    #[test]
556    fn timestamps_seats_and_schema_fail_closed() {
557        let mut value = envelope();
558        value.not_before = 0;
559        value.issued_at = 0;
560        value.expires_at = MAX_ENTITLEMENT_TIMESTAMP;
561        value.grace_until = MAX_ENTITLEMENT_TIMESTAMP;
562        value.validate().unwrap();
563        for field in 0..4 {
564            let mut invalid = envelope();
565            match field {
566                0 => invalid.issued_at = MAX_ENTITLEMENT_TIMESTAMP + 1,
567                1 => invalid.not_before = MAX_ENTITLEMENT_TIMESTAMP + 1,
568                2 => invalid.expires_at = MAX_ENTITLEMENT_TIMESTAMP + 1,
569                _ => invalid.grace_until = MAX_ENTITLEMENT_TIMESTAMP + 1,
570            }
571            assert!(invalid.validate().is_err());
572        }
573        for (not_before, issued_at, expires_at, grace_until) in [
574            (101, 100, 200, 210),
575            (99, 100, 100, 210),
576            (99, 100, 200, 199),
577        ] {
578            let mut invalid = envelope();
579            invalid.not_before = not_before;
580            invalid.issued_at = issued_at;
581            invalid.expires_at = expires_at;
582            invalid.grace_until = grace_until;
583            assert!(invalid.validate().is_err());
584        }
585        value.seats = 0;
586        assert!(value.validate().is_err());
587        value.seats = 1;
588        value.schema_version = 2;
589        assert!(value.validate().is_err());
590    }
591
592    #[test]
593    fn canonical_signature_length_alphabet_and_pad_bits_are_checked_without_trust_claim() {
594        for signature in [
595            "".to_string(),
596            "A".repeat(88),
597            "A".repeat(85) + "B==",
598            "A".repeat(85) + "_==",
599            "A".repeat(85) + "-==",
600            "A".repeat(86) + "=",
601            "é".repeat(43) + "==",
602        ] {
603            let mut invalid = envelope();
604            invalid.signature = signature;
605            assert!(invalid.validate().is_err());
606            assert!(invalid.signing_bytes().is_ok());
607        }
608        for last in ['A', 'Q', 'g', 'w'] {
609            let mut value = envelope();
610            value.signature = format!("{}{last}==", "A".repeat(85));
611            value.validate().unwrap(); // shape only; all-zero signature is not trusted
612        }
613        let mut value = envelope();
614        value.signer.algorithm = "ed25519ph".into();
615        assert!(value.validate().is_err());
616    }
617
618    #[test]
619    fn signature_coverage_includes_all_other_fields() {
620        let original = envelope();
621        let expected = original.signing_bytes().unwrap();
622        let original_json = serde_json::to_value(&original).unwrap();
623        let mutations = [
624            ("entitlement_id", serde_json::json!("ent-2")),
625            ("account_id", serde_json::json!("account-2")),
626            ("plan", serde_json::json!("team")),
627            ("seats", serde_json::json!(2)),
628            ("capabilities", serde_json::json!(["context.autopilot"])),
629            ("issued_at", serde_json::json!(101)),
630            ("not_before", serde_json::json!(98)),
631            ("expires_at", serde_json::json!(201)),
632            ("grace_until", serde_json::json!(211)),
633            ("allowed_deployments", serde_json::json!(["self_hosted"])),
634            ("deployment_id", serde_json::json!("deployment-1")),
635            ("org_id", serde_json::json!("org-1")),
636            ("workspace_id", serde_json::json!("workspace-1")),
637        ];
638        for (field, mutation) in mutations {
639            let mut json = original_json.clone();
640            json[field] = mutation;
641            let value: EntitlementEnvelopeV1 = serde_json::from_value(json).unwrap();
642            assert_ne!(value.signing_bytes().unwrap(), expected, "{field}");
643        }
644        for field in ["key_id", "public_key_digest"] {
645            let mut json = original_json.clone();
646            json["signer"][field] = if field == "key_id" {
647                serde_json::json!("key-2")
648            } else {
649                serde_json::json!(format!("sha256:{}", "b".repeat(64)))
650            };
651            let value: EntitlementEnvelopeV1 = serde_json::from_value(json).unwrap();
652            assert_ne!(value.signing_bytes().unwrap(), expected);
653        }
654        let mut value = original.clone();
655        value.signature.replace_range(..1, "B");
656        assert_eq!(value.signing_bytes().unwrap(), expected);
657        assert_ne!(
658            value.canonical_bytes().unwrap(),
659            original.canonical_bytes().unwrap()
660        );
661    }
662}