Skip to main content

lenso_service/system_plane/
enrollment.rs

1use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
2use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier as _, VerifyingKey};
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::{Value, json};
6use sha2::{Digest as _, Sha256};
7use std::collections::{BTreeMap, BTreeSet};
8use utoipa::ToSchema;
9
10pub const ENROLLMENT_OFFER_PROTOCOL: &str = "lenso.system-plane.enrollment-offer.v1";
11pub const ENROLLMENT_RECEIPT_PROTOCOL: &str = "lenso.system-plane.enrollment-receipt.v1";
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
14#[serde(rename_all = "camelCase", deny_unknown_fields)]
15pub struct EnrollmentCapabilityGrant {
16    #[schema(pattern = r"^lenso\.system-plane\.[a-z0-9]+(?:[.-][a-z0-9]+)*\.v[1-9][0-9]*$")]
17    pub contract_id: String,
18    #[schema(pattern = r"^sha256:[0-9a-f]{64}$")]
19    pub schema_digest: String,
20    pub feature_ids: BTreeSet<String>,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
24#[serde(rename_all = "camelCase", deny_unknown_fields)]
25pub struct EnrollmentPolicyGrant {
26    #[schema(min_length = 1)]
27    pub policy_id: String,
28    #[schema(min_length = 1)]
29    pub policy_revision: String,
30    #[schema(pattern = r"^sha256:[0-9a-f]{64}$")]
31    pub policy_digest: String,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
35#[serde(rename_all = "camelCase", deny_unknown_fields)]
36pub struct EnrollmentSignature {
37    pub algorithm: EnrollmentSignatureAlgorithm,
38    #[schema(min_length = 1)]
39    pub key_id: String,
40    #[schema(pattern = r"^sha256:[0-9a-f]{64}$")]
41    pub subject_digest: String,
42    #[schema(min_length = 1)]
43    pub value: String,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
47#[serde(rename_all = "snake_case")]
48pub enum EnrollmentSignatureAlgorithm {
49    Ed25519,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
53#[serde(rename_all = "camelCase", deny_unknown_fields)]
54pub struct EnrollmentOffer {
55    pub protocol: String,
56    #[schema(min_length = 1)]
57    pub system_id: String,
58    #[schema(min_length = 1)]
59    pub console_service_principal: String,
60    #[schema(min_length = 16)]
61    pub nonce: String,
62    pub issued_at_unix_ms: u64,
63    pub expires_at_unix_ms: u64,
64    pub requested_capabilities: Vec<EnrollmentCapabilityGrant>,
65    pub requested_policy: EnrollmentPolicyGrant,
66    pub signature: EnrollmentSignature,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
70#[serde(rename_all = "camelCase", deny_unknown_fields)]
71pub struct EnrollmentReceipt {
72    pub protocol: String,
73    #[schema(pattern = r"^sha256:[0-9a-f]{64}$")]
74    pub offer_digest: String,
75    #[schema(min_length = 1)]
76    pub system_id: String,
77    #[schema(min_length = 1)]
78    pub managed_service_id: String,
79    #[schema(min_length = 1)]
80    pub managed_service_principal: String,
81    #[schema(min_length = 1)]
82    pub managed_service_revision: String,
83    #[schema(min_length = 1)]
84    pub console_service_principal: String,
85    #[schema(min_length = 16)]
86    pub nonce: String,
87    pub issued_at_unix_ms: u64,
88    pub expires_at_unix_ms: u64,
89    #[schema(minimum = 1)]
90    pub grant_revision: u64,
91    pub authorization_epoch: u64,
92    pub granted_capabilities: Vec<EnrollmentCapabilityGrant>,
93    pub granted_policy: EnrollmentPolicyGrant,
94    pub signature: EnrollmentSignature,
95}
96
97/// Identity and authorization evidence extracted only after both sides of an
98/// enrollment exchange have been cryptographically verified.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
100#[serde(rename_all = "camelCase", deny_unknown_fields)]
101pub struct VerifiedEnrollmentExchange {
102    #[schema(pattern = r"^sha256:[0-9a-f]{64}$")]
103    pub offer_digest: String,
104    #[schema(pattern = r"^sha256:[0-9a-f]{64}$")]
105    pub receipt_digest: String,
106    pub system_id: String,
107    pub console_service_principal: String,
108    pub managed_service_id: String,
109    pub managed_service_principal: String,
110    pub managed_service_revision: String,
111    pub expires_at_unix_ms: u64,
112    pub grant_revision: u64,
113    pub authorization_epoch: u64,
114    pub granted_capabilities: Vec<EnrollmentCapabilityGrant>,
115    pub granted_policy: EnrollmentPolicyGrant,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum EnrollmentContractIssueCode {
120    InvalidProtocol,
121    MissingIdentity,
122    InvalidNonce,
123    InvalidLifetime,
124    InvalidCapability,
125    DuplicateCapability,
126    InvalidPolicy,
127    InvalidRevision,
128    DigestMismatch,
129    SignatureInvalid,
130    SignerUntrusted,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct EnrollmentContractIssue {
135    pub code: EnrollmentContractIssueCode,
136    pub path: String,
137    pub message: String,
138}
139
140pub trait EnrollmentSigner: std::fmt::Debug + Send + Sync {
141    fn key_id(&self) -> &str;
142    fn sign_digest(&self, subject_digest: &str) -> Result<EnrollmentSignature, String>;
143}
144
145pub trait EnrollmentSignatureVerifier: std::fmt::Debug + Send + Sync {
146    fn verify_signature(
147        &self,
148        signature: &EnrollmentSignature,
149    ) -> Result<(), EnrollmentSignatureVerificationError>;
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum EnrollmentSignatureVerificationError {
154    UntrustedKey,
155    RevokedKey,
156    InvalidEncoding,
157    InvalidSignature,
158}
159
160pub struct Ed25519EnrollmentSigner {
161    key_id: String,
162    signing_key: SigningKey,
163}
164
165impl std::fmt::Debug for Ed25519EnrollmentSigner {
166    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        formatter
168            .debug_struct("Ed25519EnrollmentSigner")
169            .field("key_id", &self.key_id)
170            .field("signing_key", &"[REDACTED]")
171            .finish()
172    }
173}
174
175impl Ed25519EnrollmentSigner {
176    pub fn new(key_id: impl Into<String>, secret_key: [u8; 32]) -> Result<Self, String> {
177        let key_id = key_id.into();
178        if key_id.trim().is_empty() {
179            return Err("Enrollment signing key identity must not be empty".to_owned());
180        }
181        Ok(Self {
182            key_id,
183            signing_key: SigningKey::from_bytes(&secret_key),
184        })
185    }
186
187    #[must_use]
188    pub fn verifying_key_bytes(&self) -> [u8; 32] {
189        self.signing_key.verifying_key().to_bytes()
190    }
191}
192
193impl EnrollmentSigner for Ed25519EnrollmentSigner {
194    fn key_id(&self) -> &str {
195        &self.key_id
196    }
197
198    fn sign_digest(&self, subject_digest: &str) -> Result<EnrollmentSignature, String> {
199        let signature = self.signing_key.sign(subject_digest.as_bytes());
200        Ok(EnrollmentSignature {
201            algorithm: EnrollmentSignatureAlgorithm::Ed25519,
202            key_id: self.key_id.clone(),
203            subject_digest: subject_digest.to_owned(),
204            value: URL_SAFE_NO_PAD.encode(signature.to_bytes()),
205        })
206    }
207}
208
209#[derive(Debug, Clone, Default)]
210pub struct Ed25519EnrollmentTrustStore {
211    verifying_keys: BTreeMap<String, VerifyingKey>,
212    revoked_key_ids: BTreeSet<String>,
213}
214
215impl Ed25519EnrollmentTrustStore {
216    pub fn new<I, K>(keys: I) -> Result<Self, String>
217    where
218        I: IntoIterator<Item = (K, [u8; 32])>,
219        K: Into<String>,
220    {
221        let mut verifying_keys = BTreeMap::new();
222        for (key_id, bytes) in keys {
223            let key_id = key_id.into();
224            if key_id.trim().is_empty() {
225                return Err("Enrollment verifying key identity must not be empty".to_owned());
226            }
227            let key = VerifyingKey::from_bytes(&bytes)
228                .map_err(|_| "Enrollment verifying key is invalid".to_owned())?;
229            verifying_keys.insert(key_id, key);
230        }
231        Ok(Self {
232            verifying_keys,
233            revoked_key_ids: BTreeSet::new(),
234        })
235    }
236
237    #[must_use]
238    pub fn with_revoked(mut self, key_id: impl Into<String>) -> Self {
239        self.revoked_key_ids.insert(key_id.into());
240        self
241    }
242}
243
244impl EnrollmentSignatureVerifier for Ed25519EnrollmentTrustStore {
245    fn verify_signature(
246        &self,
247        signature: &EnrollmentSignature,
248    ) -> Result<(), EnrollmentSignatureVerificationError> {
249        if self.revoked_key_ids.contains(&signature.key_id) {
250            return Err(EnrollmentSignatureVerificationError::RevokedKey);
251        }
252        let key = self
253            .verifying_keys
254            .get(&signature.key_id)
255            .ok_or(EnrollmentSignatureVerificationError::UntrustedKey)?;
256        let bytes = URL_SAFE_NO_PAD
257            .decode(&signature.value)
258            .map_err(|_| EnrollmentSignatureVerificationError::InvalidEncoding)?;
259        let decoded_signature = Signature::try_from(bytes.as_slice())
260            .map_err(|_| EnrollmentSignatureVerificationError::InvalidEncoding)?;
261        key.verify(signature.subject_digest.as_bytes(), &decoded_signature)
262            .map_err(|_| EnrollmentSignatureVerificationError::InvalidSignature)
263    }
264}
265
266pub fn sign_enrollment_offer(
267    mut offer: EnrollmentOffer,
268    signer: &dyn EnrollmentSigner,
269) -> Result<EnrollmentOffer, Vec<EnrollmentContractIssue>> {
270    offer.protocol = ENROLLMENT_OFFER_PROTOCOL.to_owned();
271    offer
272        .requested_capabilities
273        .sort_by(|left, right| left.contract_id.cmp(&right.contract_id));
274    offer.signature = unsigned_signature(signer.key_id());
275    let issues = validate_offer_content(&offer);
276    if !issues.is_empty() {
277        return Err(issues);
278    }
279    let digest = enrollment_offer_digest(&offer);
280    offer.signature = signer.sign_digest(&digest).map_err(|message| {
281        vec![issue(
282            EnrollmentContractIssueCode::SignatureInvalid,
283            "$.signature",
284            message,
285        )]
286    })?;
287    Ok(offer)
288}
289
290pub fn verify_enrollment_offer(
291    offer: &EnrollmentOffer,
292    verifier: &dyn EnrollmentSignatureVerifier,
293    now_unix_ms: u64,
294) -> Result<String, Vec<EnrollmentContractIssue>> {
295    let mut issues = validate_offer_content(offer);
296    let digest = enrollment_offer_digest(offer);
297    verify_bound_signature(&offer.signature, &digest, verifier, &mut issues);
298    if offer.expires_at_unix_ms <= now_unix_ms {
299        issues.push(issue(
300            EnrollmentContractIssueCode::InvalidLifetime,
301            "$.expiresAtUnixMs",
302            "Enrollment Offer has expired",
303        ));
304    }
305    if issues.is_empty() {
306        Ok(digest)
307    } else {
308        Err(issues)
309    }
310}
311
312pub fn sign_enrollment_receipt(
313    mut receipt: EnrollmentReceipt,
314    signer: &dyn EnrollmentSigner,
315) -> Result<EnrollmentReceipt, Vec<EnrollmentContractIssue>> {
316    receipt.protocol = ENROLLMENT_RECEIPT_PROTOCOL.to_owned();
317    receipt
318        .granted_capabilities
319        .sort_by(|left, right| left.contract_id.cmp(&right.contract_id));
320    receipt.signature = unsigned_signature(signer.key_id());
321    let issues = validate_receipt_content(&receipt);
322    if !issues.is_empty() {
323        return Err(issues);
324    }
325    let digest = enrollment_receipt_digest(&receipt);
326    receipt.signature = signer.sign_digest(&digest).map_err(|message| {
327        vec![issue(
328            EnrollmentContractIssueCode::SignatureInvalid,
329            "$.signature",
330            message,
331        )]
332    })?;
333    Ok(receipt)
334}
335
336pub fn verify_enrollment_receipt(
337    receipt: &EnrollmentReceipt,
338    offer: &EnrollmentOffer,
339    verifier: &dyn EnrollmentSignatureVerifier,
340    now_unix_ms: u64,
341) -> Result<String, Vec<EnrollmentContractIssue>> {
342    let mut issues = validate_receipt_content(receipt);
343    let expected_offer_digest = enrollment_offer_digest(offer);
344    if receipt.offer_digest != expected_offer_digest
345        || receipt.system_id != offer.system_id
346        || receipt.console_service_principal != offer.console_service_principal
347        || receipt.nonce != offer.nonce
348    {
349        issues.push(issue(
350            EnrollmentContractIssueCode::DigestMismatch,
351            "$.offerDigest",
352            "Enrollment Receipt is not bound to the exact Enrollment Offer",
353        ));
354    }
355    if receipt.issued_at_unix_ms < offer.issued_at_unix_ms
356        || receipt.expires_at_unix_ms > offer.expires_at_unix_ms
357        || receipt.expires_at_unix_ms <= now_unix_ms
358    {
359        issues.push(issue(
360            EnrollmentContractIssueCode::InvalidLifetime,
361            "$.expiresAtUnixMs",
362            "Enrollment Receipt lifetime is expired or exceeds its bound Offer",
363        ));
364    }
365    if receipt.granted_policy != offer.requested_policy
366        || receipt.granted_capabilities.iter().any(|granted| {
367            !offer.requested_capabilities.iter().any(|requested| {
368                requested.contract_id == granted.contract_id
369                    && requested.schema_digest == granted.schema_digest
370                    && granted.feature_ids.is_subset(&requested.feature_ids)
371            })
372        })
373    {
374        issues.push(issue(
375            EnrollmentContractIssueCode::InvalidCapability,
376            "$.grantedCapabilities",
377            "Enrollment Receipt cannot widen or substitute the Offer capability and policy request",
378        ));
379    }
380    let digest = enrollment_receipt_digest(receipt);
381    verify_bound_signature(&receipt.signature, &digest, verifier, &mut issues);
382    if issues.is_empty() {
383        Ok(digest)
384    } else {
385        Err(issues)
386    }
387}
388
389/// Verifies the Console-signed Offer and Service-signed Receipt as one
390/// bilateral exchange before exposing registration evidence to a caller.
391///
392/// This is the preferred consumer boundary. Calling only
393/// [`verify_enrollment_receipt`] proves the Service signature and artifact
394/// binding, but intentionally cannot prove that the Offer signer is trusted by
395/// the Console deployment.
396pub fn verify_enrollment_exchange(
397    offer: &EnrollmentOffer,
398    receipt: &EnrollmentReceipt,
399    console_verifier: &dyn EnrollmentSignatureVerifier,
400    service_verifier: &dyn EnrollmentSignatureVerifier,
401    now_unix_ms: u64,
402) -> Result<VerifiedEnrollmentExchange, Vec<EnrollmentContractIssue>> {
403    let offer_digest = verify_enrollment_offer(offer, console_verifier, now_unix_ms)?;
404    let receipt_digest = verify_enrollment_receipt(receipt, offer, service_verifier, now_unix_ms)?;
405    Ok(VerifiedEnrollmentExchange {
406        offer_digest,
407        receipt_digest,
408        system_id: receipt.system_id.clone(),
409        console_service_principal: receipt.console_service_principal.clone(),
410        managed_service_id: receipt.managed_service_id.clone(),
411        managed_service_principal: receipt.managed_service_principal.clone(),
412        managed_service_revision: receipt.managed_service_revision.clone(),
413        expires_at_unix_ms: receipt.expires_at_unix_ms,
414        grant_revision: receipt.grant_revision,
415        authorization_epoch: receipt.authorization_epoch,
416        granted_capabilities: receipt.granted_capabilities.clone(),
417        granted_policy: receipt.granted_policy.clone(),
418    })
419}
420
421#[must_use]
422pub fn enrollment_offer_digest(offer: &EnrollmentOffer) -> String {
423    digest_without_signature(offer)
424}
425
426#[must_use]
427pub fn enrollment_receipt_digest(receipt: &EnrollmentReceipt) -> String {
428    digest_without_signature(receipt)
429}
430
431#[must_use]
432pub fn enrollment_offer_schema() -> Value {
433    let mut schema = contract_schema::<EnrollmentOffer>(
434        "https://contracts.lenso.local/system-plane/lenso.system-plane.enrollment-offer.v1.schema.json",
435        "Lenso System Plane Enrollment Offer",
436        ENROLLMENT_OFFER_PROTOCOL,
437    );
438    patch_common_schema(&mut schema);
439    schema
440}
441
442#[must_use]
443pub fn enrollment_receipt_schema() -> Value {
444    let mut schema = contract_schema::<EnrollmentReceipt>(
445        "https://contracts.lenso.local/system-plane/lenso.system-plane.enrollment-receipt.v1.schema.json",
446        "Lenso System Plane Enrollment Receipt",
447        ENROLLMENT_RECEIPT_PROTOCOL,
448    );
449    patch_common_schema(&mut schema);
450    schema["properties"]["offerDigest"]["pattern"] = json!("^sha256:[0-9a-f]{64}$");
451    schema["properties"]["grantRevision"]["minimum"] = json!(1);
452    for field in [
453        "managedServiceId",
454        "managedServicePrincipal",
455        "managedServiceRevision",
456    ] {
457        schema["properties"][field]["minLength"] = json!(1);
458    }
459    schema
460}
461
462fn validate_offer_content(offer: &EnrollmentOffer) -> Vec<EnrollmentContractIssue> {
463    let mut issues = Vec::new();
464    if offer.protocol != ENROLLMENT_OFFER_PROTOCOL {
465        issues.push(issue(
466            EnrollmentContractIssueCode::InvalidProtocol,
467            "$.protocol",
468            "Enrollment Offer protocol is unsupported",
469        ));
470    }
471    validate_common(
472        &offer.system_id,
473        &offer.console_service_principal,
474        &offer.nonce,
475        offer.issued_at_unix_ms,
476        offer.expires_at_unix_ms,
477        &offer.requested_capabilities,
478        &offer.requested_policy,
479        &mut issues,
480    );
481    issues
482}
483
484fn validate_receipt_content(receipt: &EnrollmentReceipt) -> Vec<EnrollmentContractIssue> {
485    let mut issues = Vec::new();
486    if receipt.protocol != ENROLLMENT_RECEIPT_PROTOCOL {
487        issues.push(issue(
488            EnrollmentContractIssueCode::InvalidProtocol,
489            "$.protocol",
490            "Enrollment Receipt protocol is unsupported",
491        ));
492    }
493    validate_common(
494        &receipt.system_id,
495        &receipt.console_service_principal,
496        &receipt.nonce,
497        receipt.issued_at_unix_ms,
498        receipt.expires_at_unix_ms,
499        &receipt.granted_capabilities,
500        &receipt.granted_policy,
501        &mut issues,
502    );
503    if receipt.managed_service_id.trim().is_empty()
504        || receipt.managed_service_principal.trim().is_empty()
505        || receipt.managed_service_revision.trim().is_empty()
506    {
507        issues.push(issue(
508            EnrollmentContractIssueCode::MissingIdentity,
509            "$.managedServiceId",
510            "Enrollment Receipt requires the managed Service identity, principal, and revision",
511        ));
512    }
513    if !valid_digest(&receipt.offer_digest) {
514        issues.push(issue(
515            EnrollmentContractIssueCode::DigestMismatch,
516            "$.offerDigest",
517            "Enrollment Receipt requires a canonical Offer digest",
518        ));
519    }
520    if receipt.grant_revision == 0 {
521        issues.push(issue(
522            EnrollmentContractIssueCode::InvalidRevision,
523            "$.grantRevision",
524            "Enrollment Grant revision must be positive",
525        ));
526    }
527    issues
528}
529
530fn validate_common(
531    system_id: &str,
532    console_principal: &str,
533    nonce: &str,
534    issued_at: u64,
535    expires_at: u64,
536    capabilities: &[EnrollmentCapabilityGrant],
537    policy: &EnrollmentPolicyGrant,
538    issues: &mut Vec<EnrollmentContractIssue>,
539) {
540    if system_id.trim().is_empty() || console_principal.trim().is_empty() {
541        issues.push(issue(
542            EnrollmentContractIssueCode::MissingIdentity,
543            "$.systemId",
544            "Enrollment requires System and Console Service identities",
545        ));
546    }
547    if nonce.len() < 16 {
548        issues.push(issue(
549            EnrollmentContractIssueCode::InvalidNonce,
550            "$.nonce",
551            "Enrollment nonce must contain at least 16 characters",
552        ));
553    }
554    if issued_at == 0 || expires_at <= issued_at {
555        issues.push(issue(
556            EnrollmentContractIssueCode::InvalidLifetime,
557            "$.expiresAtUnixMs",
558            "Enrollment expiry must follow a positive issue time",
559        ));
560    }
561    let mut seen = BTreeSet::new();
562    for (index, capability) in capabilities.iter().enumerate() {
563        if !seen.insert(&capability.contract_id) {
564            issues.push(issue(
565                EnrollmentContractIssueCode::DuplicateCapability,
566                format!("$.capabilities[{index}]"),
567                "Enrollment capability contracts must be unique",
568            ));
569        }
570        if index > 0 && capabilities[index - 1].contract_id >= capability.contract_id {
571            issues.push(issue(
572                EnrollmentContractIssueCode::InvalidCapability,
573                format!("$.capabilities[{index}]"),
574                "Enrollment capability contracts must use canonical contract order",
575            ));
576        }
577        if !valid_contract_id(&capability.contract_id)
578            || !valid_digest(&capability.schema_digest)
579            || capability
580                .feature_ids
581                .iter()
582                .any(|feature| !valid_feature_id(feature))
583        {
584            issues.push(issue(
585                EnrollmentContractIssueCode::InvalidCapability,
586                format!("$.capabilities[{index}]"),
587                "Enrollment capability grant is not canonical",
588            ));
589        }
590    }
591    if policy.policy_id.trim().is_empty()
592        || policy.policy_revision.trim().is_empty()
593        || !valid_digest(&policy.policy_digest)
594    {
595        issues.push(issue(
596            EnrollmentContractIssueCode::InvalidPolicy,
597            "$.policy",
598            "Enrollment policy identity, revision, and digest are required",
599        ));
600    }
601}
602
603fn verify_bound_signature(
604    signature: &EnrollmentSignature,
605    digest: &str,
606    verifier: &dyn EnrollmentSignatureVerifier,
607    issues: &mut Vec<EnrollmentContractIssue>,
608) {
609    if signature.subject_digest != digest {
610        issues.push(issue(
611            EnrollmentContractIssueCode::DigestMismatch,
612            "$.signature.subjectDigest",
613            "Enrollment signature is not bound to the canonical artifact digest",
614        ));
615        return;
616    }
617    if let Err(verification_error) = verifier.verify_signature(signature) {
618        let (code, message) = match verification_error {
619            EnrollmentSignatureVerificationError::UntrustedKey => (
620                EnrollmentContractIssueCode::SignerUntrusted,
621                "Enrollment signing key is not trusted",
622            ),
623            EnrollmentSignatureVerificationError::RevokedKey => (
624                EnrollmentContractIssueCode::SignerUntrusted,
625                "Enrollment signing key is revoked",
626            ),
627            EnrollmentSignatureVerificationError::InvalidEncoding => (
628                EnrollmentContractIssueCode::SignatureInvalid,
629                "Enrollment signature encoding is invalid",
630            ),
631            EnrollmentSignatureVerificationError::InvalidSignature => (
632                EnrollmentContractIssueCode::SignatureInvalid,
633                "Enrollment signature is invalid",
634            ),
635        };
636        issues.push(issue(code, "$.signature", message));
637    }
638}
639
640fn digest_without_signature<T: Serialize>(artifact: &T) -> String {
641    let mut value = serde_json::to_value(artifact).expect("Enrollment artifact serializes");
642    value
643        .as_object_mut()
644        .expect("Enrollment artifact is an object")
645        .remove("signature");
646    let bytes = serde_json::to_vec(&value).expect("Enrollment artifact serializes to bytes");
647    format!("sha256:{}", hex(&Sha256::digest(bytes)))
648}
649
650fn unsigned_signature(key_id: &str) -> EnrollmentSignature {
651    EnrollmentSignature {
652        algorithm: EnrollmentSignatureAlgorithm::Ed25519,
653        key_id: key_id.to_owned(),
654        subject_digest: String::new(),
655        value: String::new(),
656    }
657}
658
659fn contract_schema<T: JsonSchema>(id: &str, title: &str, protocol: &str) -> Value {
660    let mut schema =
661        serde_json::to_value(schemars::schema_for!(T)).expect("Enrollment schema serializes");
662    schema["$id"] = Value::String(id.to_owned());
663    schema["title"] = Value::String(title.to_owned());
664    schema["properties"]["protocol"] = json!({ "const": protocol });
665    schema
666}
667
668fn patch_common_schema(schema: &mut Value) {
669    for field in ["systemId", "consoleServicePrincipal"] {
670        schema["properties"][field]["minLength"] = json!(1);
671    }
672    schema["properties"]["nonce"]["minLength"] = json!(16);
673    schema["properties"]["issuedAtUnixMs"]["minimum"] = json!(1);
674    schema["properties"]["expiresAtUnixMs"]["minimum"] = json!(1);
675    let capability = &mut schema["$defs"]["EnrollmentCapabilityGrant"]["properties"];
676    capability["contractId"]["pattern"] =
677        json!(r"^lenso\.system-plane\.[a-z0-9]+(?:[.-][a-z0-9]+)*\.v[1-9][0-9]*$");
678    capability["schemaDigest"]["pattern"] = json!("^sha256:[0-9a-f]{64}$");
679    capability["featureIds"]["items"]["pattern"] = json!("^[a-z0-9]+(?:-[a-z0-9]+)*$");
680    let policy = &mut schema["$defs"]["EnrollmentPolicyGrant"]["properties"];
681    policy["policyId"]["minLength"] = json!(1);
682    policy["policyRevision"]["minLength"] = json!(1);
683    policy["policyDigest"]["pattern"] = json!("^sha256:[0-9a-f]{64}$");
684    let signature = &mut schema["$defs"]["EnrollmentSignature"]["properties"];
685    signature["keyId"]["minLength"] = json!(1);
686    signature["subjectDigest"]["pattern"] = json!("^sha256:[0-9a-f]{64}$");
687    signature["value"]["pattern"] = json!("^[A-Za-z0-9_-]{86}$");
688}
689
690fn valid_contract_id(value: &str) -> bool {
691    let Some((prefix, major)) = value.rsplit_once(".v") else {
692        return false;
693    };
694    prefix.starts_with("lenso.system-plane.")
695        && !prefix.ends_with('.')
696        && prefix.bytes().all(|byte| {
697            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'-')
698        })
699        && !major.starts_with('0')
700        && major.parse::<u32>().is_ok_and(|major| major > 0)
701}
702
703fn valid_feature_id(value: &str) -> bool {
704    !value.is_empty()
705        && !value.starts_with('-')
706        && !value.ends_with('-')
707        && value
708            .bytes()
709            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
710}
711
712fn valid_digest(value: &str) -> bool {
713    value.strip_prefix("sha256:").is_some_and(|digest| {
714        digest.len() == 64
715            && digest
716                .bytes()
717                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
718    })
719}
720
721fn hex(bytes: &[u8]) -> String {
722    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
723}
724
725fn issue(
726    code: EnrollmentContractIssueCode,
727    path: impl Into<String>,
728    message: impl Into<String>,
729) -> EnrollmentContractIssue {
730    EnrollmentContractIssue {
731        code,
732        path: path.into(),
733        message: message.into(),
734    }
735}